From b9ea64ff95a23f2947403516d0a227322c4b6317 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 19 Jun 2025 08:51:15 +0100 Subject: [PATCH 01/58] Refactor User#find_or_create_from_cis2_oidc This refactors the method to use `slice` to get the email address, given name and family name. Jira-Issue: MAV-1353 --- app/models/user.rb | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 028535c999..9a0091203f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -81,11 +81,12 @@ def self.find_or_create_from_cis2_oidc(userinfo) uid: userinfo[:uid] ) - user.family_name = userinfo[:extra][:raw_info][:family_name] - user.given_name = userinfo[:extra][:raw_info][:given_name] - user.email = userinfo[:info][:email] - user.session_token = - userinfo[:extra][:raw_info][:sid].presence || Devise.friendly_token + raw_info = userinfo[:extra][:raw_info] + + user.assign_attributes( + raw_info.slice(:email, :family_name, :given_name).to_h + ) + user.session_token = raw_info[:sid].presence || Devise.friendly_token user.tap(&:save!) end From aa0de6afac22d1289c15df3e66665ad11f9e7919 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 19 Jun 2025 09:37:42 +0100 Subject: [PATCH 02/58] Attach users to organisations when signing in This makes it so that when a user signs in using CIS2, we attach their user to the organisation they signed in to. This is useful when being able to display a list of users associated with an organisation. It doesn't handle the situation where a user leaves an organisation, however such users will no longer be able to sign in, and it could be useful to have a record of previous users. Specifically, this change helps us towards two improvements: - Fixing a bug where no nurses are displayed in the offline spreadsheet. - Add the ability to choose which nurse performed pre-screening and identity checks when a health care assistant administers the vaccine. Jira-Issue: MAV-1353 --- .../users/omniauth_callbacks_controller.rb | 10 ++++++---- app/models/user.rb | 14 +++++++++++--- ...ser_cis2_authentication_from_start_page_spec.rb | 8 ++++++++ spec/policies/vaccination_record_policy_spec.rb | 4 ++-- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index c8d6152129..218f62dcb5 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -21,7 +21,7 @@ def cis2 elsif !selected_cis2_org_is_registered? redirect_to users_team_not_found_path else - @user = User.find_or_create_from_cis2_oidc(user_cis2_info) + @user = User.find_or_create_from_cis2_oidc(user_cis2_info, team) # Force is set to true because the `session_token` might have changed # even if the same user is logging in. @@ -86,14 +86,16 @@ def verify_cis2_response end end - def user_cis2_info - request.env["omniauth.auth"] - end + def user_cis2_info = request.env["omniauth.auth"] def raw_cis2_info user_cis2_info["extra"]["raw_info"] end + def team + @team ||= Team.find_by(ods_code: selected_cis2_org["org_code"]) + end + def set_cis2_session_info session["cis2_info"] = { "selected_org" => { diff --git a/app/models/user.rb b/app/models/user.rb index 9a0091203f..284a3d33fd 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -74,7 +74,7 @@ class User < ApplicationRecord delegate :fhir_practitioner, to: :fhir_mapper - def self.find_or_create_from_cis2_oidc(userinfo) + def self.find_or_create_from_cis2_oidc(userinfo, team) user = User.find_or_initialize_by( provider: userinfo[:provider], @@ -88,13 +88,21 @@ def self.find_or_create_from_cis2_oidc(userinfo) ) user.session_token = raw_info[:sid].presence || Devise.friendly_token - user.tap(&:save!) + ActiveRecord::Base.transaction do + user.save! + + user.teams << team unless user.teams.include?(team) + + user + end end def selected_team @selected_team ||= if cis2_info.present? - Team.find_by(ods_code: cis2_info.dig("selected_org", "code")) + Team.includes(:programmes).find_by( + ods_code: cis2_info.dig("selected_org", "code") + ) end end diff --git a/spec/features/user_cis2_authentication_from_start_page_spec.rb b/spec/features/user_cis2_authentication_from_start_page_spec.rb index 0ae8c7d374..d40a35f317 100644 --- a/spec/features/user_cis2_authentication_from_start_page_spec.rb +++ b/spec/features/user_cis2_authentication_from_start_page_spec.rb @@ -9,6 +9,7 @@ when_i_click_the_cis2_login_button then_i_see_the_dashboard and_i_am_logged_in + and_i_am_added_to_the_team when_i_click_the_change_role_button then_i_see_the_dashboard @@ -26,6 +27,7 @@ when_i_click_the_cis2_login_button then_i_see_the_sessions_page and_i_am_logged_in + and_i_am_added_to_the_team end def given_a_test_team_is_setup_in_mavis_and_cis2 @@ -61,6 +63,12 @@ def and_i_am_logged_in expect(page).to have_button "Log out" end + def and_i_am_added_to_the_team + user = User.first + expect(user).not_to be_nil + expect(user.teams).to include(@team) + end + def when_i_click_the_change_role_button click_button "Change role" end diff --git a/spec/policies/vaccination_record_policy_spec.rb b/spec/policies/vaccination_record_policy_spec.rb index 68e06b9d88..7b2ee6fb24 100644 --- a/spec/policies/vaccination_record_policy_spec.rb +++ b/spec/policies/vaccination_record_policy_spec.rb @@ -12,7 +12,7 @@ let(:vaccination_record) { create(:vaccination_record, programme:) } context "with an admin" do - let(:user) { build(:admin, teams: [team]) } + let(:user) { create(:admin, teams: [team]) } it { should be(false) } @@ -27,7 +27,7 @@ end context "with a nurse" do - let(:user) { build(:nurse, teams: [team]) } + let(:user) { create(:nurse, teams: [team]) } it { should be(false) } From 0993f5b4725fd40602e64ca0a65f966c3f375ad6 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 19 Jun 2025 17:03:18 +0100 Subject: [PATCH 03/58] Add healthcase assistant fallback role This is going to be useful as start to build out delegation, even if this role doesn't exist properly yet in CIS2. Jira-Issue: MAV-1365 --- app/models/user.rb | 21 +++++++++++++++++++-- spec/factories/users.rb | 4 ++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 028535c999..7dcab1ec63 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -70,7 +70,9 @@ class User < ApplicationRecord scope :recently_active, -> { where(last_sign_in_at: 1.week.ago..Time.current) } - enum :fallback_role, { nurse: 0, admin: 1, superuser: 2 }, prefix: true + enum :fallback_role, + { nurse: 0, admin: 1, superuser: 2, healthcare_assistant: 3 }, + prefix: true delegate :fhir_practitioner, to: :fhir_mapper @@ -122,6 +124,13 @@ def is_superuser? false end + def is_healthcare_assistant? + # TODO: How do we determine this from CIS2? + return false if Settings.cis2.enabled + + fallback_role_healthcare_assistant? + end + def role_description role = if is_admin? @@ -132,7 +141,15 @@ def role_description "Unknown" end - is_superuser? ? "#{role} (superuser)" : role + if is_healthcare_assistant? && is_superuser? + "#{role} (Healthcare assistant and superuser)" + elsif is_healthcare_assistant? + "#{role} (Healthcare assistant)" + elsif is_superuser? + "#{role} (Superuser)" + else + role + end end private diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 4ba2215161..1522732243 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -86,6 +86,10 @@ fallback_role { :superuser } end + trait :healthcare_assistant do + fallback_role { :healthcare_assistant } + end + trait :signed_in do current_sign_in_at { Time.current } current_sign_in_ip { "127.0.0.1" } From 2fc92cc58321d79cb54a8810352ce2674491972e Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 5 Aug 2025 16:11:35 +0100 Subject: [PATCH 04/58] Rename unprocessable_entity In Rack 3.2, this has been renamed to unprocessable_content and using the old symbol is deprecated, which results in a warning message. Although a future version of Rails will handle this for us (https://github.com/rails/rails/pull/53383) but we can also change the symbol we use in our codebase to reduce the number of warnings happening at the moment. --- app/controllers/api/testing/onboard_controller.rb | 2 +- app/controllers/application_controller.rb | 2 +- app/controllers/batches_controller.rb | 4 ++-- app/controllers/class_imports_controller.rb | 2 +- app/controllers/cohort_imports_controller.rb | 2 +- app/controllers/consent_forms_controller.rb | 2 +- .../draft_vaccination_records_controller.rb | 2 +- app/controllers/errors_controller.rb | 2 +- app/controllers/immunisation_imports_controller.rb | 2 +- app/controllers/imports/issues_controller.rb | 2 +- app/controllers/offline_passwords_controller.rb | 2 +- .../consent_forms/edit_controller.rb | 2 +- app/controllers/parent_relationships_controller.rb | 2 +- .../patient_sessions/activities_controller.rb | 2 +- .../patient_sessions/consents_controller.rb | 6 +++--- .../gillick_assessments_controller.rb | 2 +- .../session_attendances_controller.rb | 2 +- .../patient_sessions/triages_controller.rb | 2 +- .../patient_sessions/vaccinations_controller.rb | 2 +- app/controllers/patients/edit_controller.rb | 2 +- app/controllers/school_moves_controller.rb | 2 +- app/controllers/session_dates_controller.rb | 2 +- app/controllers/sessions/edit_controller.rb | 12 ++++++------ app/controllers/sessions/record_controller.rb | 2 +- app/controllers/users/teams_controller.rb | 2 +- config/initializers/devise.rb | 2 +- spec/requests/api/testing/onboard_spec.rb | 2 +- spec/requests/patient_sessions/activity_spec.rb | 4 ++-- 28 files changed, 37 insertions(+), 37 deletions(-) diff --git a/app/controllers/api/testing/onboard_controller.rb b/app/controllers/api/testing/onboard_controller.rb index 22dce756dd..32c17e56bb 100644 --- a/app/controllers/api/testing/onboard_controller.rb +++ b/app/controllers/api/testing/onboard_controller.rb @@ -5,7 +5,7 @@ def create onboarding = Onboarding.new(params.to_unsafe_h) if onboarding.invalid? - render json: onboarding.errors, status: :unprocessable_entity + render json: onboarding.errors, status: :unprocessable_content else onboarding.save!(create_sessions_for_previous_academic_year: true) render status: :created diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7c5bf4427d..93c13e541f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -80,7 +80,7 @@ def set_disable_cache_headers end def handle_unprocessable_entity - render "errors/unprocessable_entity", status: :unprocessable_entity + render "errors/unprocessable_entity", status: :unprocessable_content end def user_not_authorized diff --git a/app/controllers/batches_controller.rb b/app/controllers/batches_controller.rb index f9b4bc465c..1aacb2cef2 100644 --- a/app/controllers/batches_controller.rb +++ b/app/controllers/batches_controller.rb @@ -30,7 +30,7 @@ def create } else @form.expiry = expiry_validator.date_params_as_struct - render :new, status: :unprocessable_entity + render :new, status: :unprocessable_content end end @@ -55,7 +55,7 @@ def update } else @form.expiry = expiry_validator.date_params_as_struct - render :edit, status: :unprocessable_entity + render :edit, status: :unprocessable_content end end diff --git a/app/controllers/class_imports_controller.rb b/app/controllers/class_imports_controller.rb index 8fc8e68a09..852c377ba3 100644 --- a/app/controllers/class_imports_controller.rb +++ b/app/controllers/class_imports_controller.rb @@ -25,7 +25,7 @@ def create @class_import.load_data! if @class_import.invalid? - render :new, status: :unprocessable_entity and return + render :new, status: :unprocessable_content and return end @class_import.save! diff --git a/app/controllers/cohort_imports_controller.rb b/app/controllers/cohort_imports_controller.rb index 9d1ca183cd..e620287866 100644 --- a/app/controllers/cohort_imports_controller.rb +++ b/app/controllers/cohort_imports_controller.rb @@ -23,7 +23,7 @@ def create @cohort_import.load_data! if @cohort_import.invalid? - render :new, status: :unprocessable_entity and return + render :new, status: :unprocessable_content and return end @cohort_import.save! diff --git a/app/controllers/consent_forms_controller.rb b/app/controllers/consent_forms_controller.rb index 5f297f758e..7c1e1da31b 100644 --- a/app/controllers/consent_forms_controller.rb +++ b/app/controllers/consent_forms_controller.rb @@ -68,7 +68,7 @@ def update_archive "Consent response from #{@consent_form.parent_full_name} archived" } else - render :archive, status: :unprocessable_entity + render :archive, status: :unprocessable_content end end diff --git a/app/controllers/draft_vaccination_records_controller.rb b/app/controllers/draft_vaccination_records_controller.rb index bb233848a6..b3453d7da1 100644 --- a/app/controllers/draft_vaccination_records_controller.rb +++ b/app/controllers/draft_vaccination_records_controller.rb @@ -71,7 +71,7 @@ def validate_params unless validator.date_params_valid? && time_valid @draft_vaccination_record.errors.add(:performed_at, :invalid) - render_wizard nil, status: :unprocessable_entity + render_wizard nil, status: :unprocessable_content end end end diff --git a/app/controllers/errors_controller.rb b/app/controllers/errors_controller.rb index 5c76ff126e..5d7f325f79 100644 --- a/app/controllers/errors_controller.rb +++ b/app/controllers/errors_controller.rb @@ -16,7 +16,7 @@ def not_found end def unprocessable_entity - render "unprocessable_entity", status: :unprocessable_entity + render "unprocessable_entity", status: :unprocessable_content end def too_many_requests diff --git a/app/controllers/immunisation_imports_controller.rb b/app/controllers/immunisation_imports_controller.rb index 0b16c91e67..b328f3abff 100644 --- a/app/controllers/immunisation_imports_controller.rb +++ b/app/controllers/immunisation_imports_controller.rb @@ -21,7 +21,7 @@ def create @immunisation_import.load_data! if @immunisation_import.invalid? - render :new, status: :unprocessable_entity and return + render :new, status: :unprocessable_content and return end @immunisation_import.save! diff --git a/app/controllers/imports/issues_controller.rb b/app/controllers/imports/issues_controller.rb index 3d58720679..4ba7274212 100644 --- a/app/controllers/imports/issues_controller.rb +++ b/app/controllers/imports/issues_controller.rb @@ -19,7 +19,7 @@ def update if @form.save redirect_to imports_issues_path, flash: { success: "Record updated" } else - render :show, status: :unprocessable_entity and return + render :show, status: :unprocessable_content and return end end diff --git a/app/controllers/offline_passwords_controller.rb b/app/controllers/offline_passwords_controller.rb index e806fc14fb..a99b024d0a 100644 --- a/app/controllers/offline_passwords_controller.rb +++ b/app/controllers/offline_passwords_controller.rb @@ -14,7 +14,7 @@ def create success: "Campaign saved, you can now go offline" } else - render :new, status: :unprocessable_entity + render :new, status: :unprocessable_content end end diff --git a/app/controllers/parent_interface/consent_forms/edit_controller.rb b/app/controllers/parent_interface/consent_forms/edit_controller.rb index 038b4beba1..6ed3b66571 100644 --- a/app/controllers/parent_interface/consent_forms/edit_controller.rb +++ b/app/controllers/parent_interface/consent_forms/edit_controller.rb @@ -188,7 +188,7 @@ def validate_params unless validator.date_params_valid? @consent_form.date_of_birth = validator.date_params_as_struct - render_wizard nil, status: :unprocessable_entity + render_wizard nil, status: :unprocessable_content end end end diff --git a/app/controllers/parent_relationships_controller.rb b/app/controllers/parent_relationships_controller.rb index 987b9f5469..1e5006a610 100644 --- a/app/controllers/parent_relationships_controller.rb +++ b/app/controllers/parent_relationships_controller.rb @@ -13,7 +13,7 @@ def update if @parent_relationship.update(parent_relationship_params) redirect_to edit_patient_path(@patient) else - render :edit, status: :unprocessable_entity + render :edit, status: :unprocessable_content end end diff --git a/app/controllers/patient_sessions/activities_controller.rb b/app/controllers/patient_sessions/activities_controller.rb index 8f02b3fc07..0ae367db03 100644 --- a/app/controllers/patient_sessions/activities_controller.rb +++ b/app/controllers/patient_sessions/activities_controller.rb @@ -15,7 +15,7 @@ def create success: "Note added" } else - render :show, status: :unprocessable_entity + render :show, status: :unprocessable_content end end diff --git a/app/controllers/patient_sessions/consents_controller.rb b/app/controllers/patient_sessions/consents_controller.rb index 1efdff8aaa..29ff2579fe 100644 --- a/app/controllers/patient_sessions/consents_controller.rb +++ b/app/controllers/patient_sessions/consents_controller.rb @@ -19,7 +19,7 @@ def create else render "patient_sessions/programmes/show", layout: "full", - status: :unprocessable_entity + status: :unprocessable_content end end @@ -70,7 +70,7 @@ def update_withdraw redirect_to session_patient_programme_consent_path else - render :withdraw, status: :unprocessable_entity + render :withdraw, status: :unprocessable_content end end @@ -93,7 +93,7 @@ def update_invalidate "Consent response from #{@consent.name} marked as invalid" } else - render :invalidate, status: :unprocessable_entity + render :invalidate, status: :unprocessable_content end end diff --git a/app/controllers/patient_sessions/gillick_assessments_controller.rb b/app/controllers/patient_sessions/gillick_assessments_controller.rb index cad7900da7..8d6c69a695 100644 --- a/app/controllers/patient_sessions/gillick_assessments_controller.rb +++ b/app/controllers/patient_sessions/gillick_assessments_controller.rb @@ -14,7 +14,7 @@ def update redirect_to session_patient_programme_path(@session, @patient, @programme) else - render :edit, status: :unprocessable_entity + render :edit, status: :unprocessable_content end end diff --git a/app/controllers/patient_sessions/session_attendances_controller.rb b/app/controllers/patient_sessions/session_attendances_controller.rb index b8ed779566..18965d87cd 100644 --- a/app/controllers/patient_sessions/session_attendances_controller.rb +++ b/app/controllers/patient_sessions/session_attendances_controller.rb @@ -35,7 +35,7 @@ def update @patient_session.programmes.first ) else - render :edit, status: :unprocessable_entity + render :edit, status: :unprocessable_content end end diff --git a/app/controllers/patient_sessions/triages_controller.rb b/app/controllers/patient_sessions/triages_controller.rb index 2498f6c21f..1bb20d24cc 100644 --- a/app/controllers/patient_sessions/triages_controller.rb +++ b/app/controllers/patient_sessions/triages_controller.rb @@ -50,7 +50,7 @@ def create else render "patient_sessions/programmes/show", layout: "full", - status: :unprocessable_entity + status: :unprocessable_content end end diff --git a/app/controllers/patient_sessions/vaccinations_controller.rb b/app/controllers/patient_sessions/vaccinations_controller.rb index f79488dbad..03c173c2ed 100644 --- a/app/controllers/patient_sessions/vaccinations_controller.rb +++ b/app/controllers/patient_sessions/vaccinations_controller.rb @@ -47,7 +47,7 @@ def create else render "patient_sessions/programmes/show", layout: "full", - status: :unprocessable_entity + status: :unprocessable_content end end diff --git a/app/controllers/patients/edit_controller.rb b/app/controllers/patients/edit_controller.rb index 2e3dc1297a..9d2d15dbfb 100644 --- a/app/controllers/patients/edit_controller.rb +++ b/app/controllers/patients/edit_controller.rb @@ -21,7 +21,7 @@ def update_nhs_number redirect_to edit_patient_path(@patient) else - render :nhs_number, status: :unprocessable_entity + render :nhs_number, status: :unprocessable_content end end diff --git a/app/controllers/school_moves_controller.rb b/app/controllers/school_moves_controller.rb index 0389587f85..4afcfed96e 100644 --- a/app/controllers/school_moves_controller.rb +++ b/app/controllers/school_moves_controller.rb @@ -42,7 +42,7 @@ def update redirect_to school_moves_path, flash: else - render :show, status: :unprocessable_entity + render :show, status: :unprocessable_content end end diff --git a/app/controllers/session_dates_controller.rb b/app/controllers/session_dates_controller.rb index 5050c6a085..1c05a0f1ff 100644 --- a/app/controllers/session_dates_controller.rb +++ b/app/controllers/session_dates_controller.rb @@ -11,7 +11,7 @@ def update @session.assign_attributes(remove_invalid_dates(session_params)) @session.set_notification_dates - render :show, status: :unprocessable_entity and return if @session.invalid? + render :show, status: :unprocessable_content and return if @session.invalid? @session.save! diff --git a/app/controllers/sessions/edit_controller.rb b/app/controllers/sessions/edit_controller.rb index b49d7a6787..a3e41e4277 100644 --- a/app/controllers/sessions/edit_controller.rb +++ b/app/controllers/sessions/edit_controller.rb @@ -19,7 +19,7 @@ def update_programmes if @form.save redirect_to edit_session_path(@session) else - render :programmes, status: :unprocessable_entity + render :programmes, status: :unprocessable_content end end @@ -31,9 +31,9 @@ def update_send_consent_requests_at if !send_consent_requests_at_validator.date_params_valid? @session.send_consent_requests_at = send_consent_requests_at_validator.date_params_as_struct - render :send_consent_requests_at, status: :unprocessable_entity + render :send_consent_requests_at, status: :unprocessable_content elsif !@session.update(send_consent_requests_at_params) - render :send_consent_requests_at, status: :unprocessable_entity + render :send_consent_requests_at, status: :unprocessable_content else redirect_to edit_session_path(@session) end @@ -47,9 +47,9 @@ def update_send_invitations_at if !send_invitations_at_validator.date_params_valid? @session.send_invitations_at = send_invitations_at_validator.date_params_as_struct - render :send_invitations_at, status: :unprocessable_entity + render :send_invitations_at, status: :unprocessable_content elsif !@session.update(send_invitations_at_params) - render :send_invitations_at, status: :unprocessable_entity + render :send_invitations_at, status: :unprocessable_content else redirect_to edit_session_path(@session) end @@ -63,7 +63,7 @@ def update_weeks_before_consent_reminders if @session.update(weeks_before_consent_reminders_params) redirect_to edit_session_path(@session) else - render :weeks_before_consent_reminders, status: :unprocessable_entity + render :weeks_before_consent_reminders, status: :unprocessable_content end end diff --git a/app/controllers/sessions/record_controller.rb b/app/controllers/sessions/record_controller.rb index 74c26828e0..ffbd77d3c7 100644 --- a/app/controllers/sessions/record_controller.rb +++ b/app/controllers/sessions/record_controller.rb @@ -59,7 +59,7 @@ def update_batch @todays_batch = Batch.new @todays_batch.errors.add(:id, "Select a default batch for this session") - render :batch, status: :unprocessable_entity + render :batch, status: :unprocessable_content end end diff --git a/app/controllers/users/teams_controller.rb b/app/controllers/users/teams_controller.rb index d86cc4dfc6..04937ece17 100644 --- a/app/controllers/users/teams_controller.rb +++ b/app/controllers/users/teams_controller.rb @@ -23,7 +23,7 @@ def create if @form.save redirect_to dashboard_path else - render :new, status: :unprocessable_entity + render :new, status: :unprocessable_content end end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 866fd1ecf4..4a2f38d76a 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -379,7 +379,7 @@ # apps is `200 OK` and `302 Found respectively`, but new apps are generated with # these new defaults that match Hotwire/Turbo behavior. # Note: These might become the new default in future versions of Devise. - config.responder.error_status = :unprocessable_entity + config.responder.error_status = :unprocessable_content config.responder.redirect_status = :see_other # ==> Configuration for :registerable diff --git a/spec/requests/api/testing/onboard_spec.rb b/spec/requests/api/testing/onboard_spec.rb index f7524f55df..f6244ab7bb 100644 --- a/spec/requests/api/testing/onboard_spec.rb +++ b/spec/requests/api/testing/onboard_spec.rb @@ -51,7 +51,7 @@ it "responds with an error" do request - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) errors = JSON.parse(response.body) diff --git a/spec/requests/patient_sessions/activity_spec.rb b/spec/requests/patient_sessions/activity_spec.rb index e81bc50a1c..bbcd677e7d 100644 --- a/spec/requests/patient_sessions/activity_spec.rb +++ b/spec/requests/patient_sessions/activity_spec.rb @@ -32,13 +32,13 @@ it "validates the body is present" do post path, params: { note: { body: "" } } - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) expect(response.body).to include("Enter a note") end it "validates the body isn't too long" do post path, params: { note: { body: "a" * 2000 } } - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) expect(response.body).to include( "Enter a note that is less than 1000 characters long" ) From 5e8ad0bcf751113e8c5d546d4ab460f1f4884fcf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Aug 2025 17:18:49 +0000 Subject: [PATCH 05/58] Bump aws-sdk-accessanalyzer from 1.75.0 to 1.76.0 Bumps [aws-sdk-accessanalyzer](https://github.com/aws/aws-sdk-ruby) from 1.75.0 to 1.76.0. - [Release notes](https://github.com/aws/aws-sdk-ruby/releases) - [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-accessanalyzer/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-ruby/commits) --- updated-dependencies: - dependency-name: aws-sdk-accessanalyzer dependency-version: 1.76.0 dependency-type: direct:development 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 164f3682e6..e3980c61ec 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -114,7 +114,7 @@ GEM attr_required (1.0.2) aws-eventstream (1.4.0) aws-partitions (1.1141.0) - aws-sdk-accessanalyzer (1.75.0) + aws-sdk-accessanalyzer (1.76.0) aws-sdk-core (~> 3, >= 3.228.0) aws-sigv4 (~> 1.5) aws-sdk-core (3.229.0) From b2434e496e6583dd3ba87dd957588137b0922b9c Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 23 Jul 2025 21:25:54 +0100 Subject: [PATCH 06/58] Remove unused organisation personalisation This removes two unused organisation personalisation variables that are no longer being used. Jira-Issue: MAV-1280 --- app/lib/govuk_notify_personalisation.rb | 2 -- spec/lib/govuk_notify_personalisation_spec.rb | 2 -- 2 files changed, 4 deletions(-) diff --git a/app/lib/govuk_notify_personalisation.rb b/app/lib/govuk_notify_personalisation.rb index e13823a1f9..75c87c2f65 100644 --- a/app/lib/govuk_notify_personalisation.rb +++ b/app/lib/govuk_notify_personalisation.rb @@ -54,8 +54,6 @@ def to_h next_session_dates:, next_session_dates_or:, not_catch_up:, - organisation_privacy_notice_url: team_privacy_notice_url, - organisation_privacy_policy_url: team_privacy_policy_url, outcome_administered:, outcome_not_administered:, patient_date_of_birth:, diff --git a/spec/lib/govuk_notify_personalisation_spec.rb b/spec/lib/govuk_notify_personalisation_spec.rb index 52fe6fd87d..0c01dafdf2 100644 --- a/spec/lib/govuk_notify_personalisation_spec.rb +++ b/spec/lib/govuk_notify_personalisation_spec.rb @@ -76,8 +76,6 @@ next_session_dates: "Thursday 1 January", next_session_dates_or: "Thursday 1 January", not_catch_up: "yes", - organisation_privacy_notice_url: "https://example.com/privacy-notice", - organisation_privacy_policy_url: "https://example.com/privacy-policy", patient_date_of_birth: "1 February 2012", short_patient_name: "John", short_patient_name_apos: "John’s", From f839bec31c60ed5ec403c18c7f5a2176319f607e Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 23 Jul 2025 21:57:05 +0100 Subject: [PATCH 07/58] Remove ODS code from generic clinics This is no longer a suitable unique value for the generic clinics because each team will have their own generic clinic, while the ODS code will belong to the organisation at a higher level. Each organisation can have many teams, therefore many generic clinics, so the ODS codes must be left blank. Jira-Issue: MAV-1280 --- app/controllers/api/testing/teams_controller.rb | 3 +-- app/lib/generic_clinic_factory.rb | 3 +-- app/models/location.rb | 2 +- ...0250723194813_remove_ods_code_from_generic_clinics.rb | 7 +++++++ spec/factories/locations.rb | 2 -- spec/models/location_spec.rb | 9 +-------- 6 files changed, 11 insertions(+), 15 deletions(-) create mode 100644 db/migrate/20250723194813_remove_ods_code_from_generic_clinics.rb diff --git a/app/controllers/api/testing/teams_controller.rb b/app/controllers/api/testing/teams_controller.rb index 4c7875d287..52d31185b8 100644 --- a/app/controllers/api/testing/teams_controller.rb +++ b/app/controllers/api/testing/teams_controller.rb @@ -62,10 +62,9 @@ def destroy log_destroy(sessions) subteams = Subteam.where(team:) + log_destroy(Location.generic_clinic.where(subteam: subteams)) Location.where(subteam: subteams).update_all(subteam_id: nil) - log_destroy(subteams) - log_destroy(Location.generic_clinic.where(ods_code: team.ods_code)) log_destroy(TeamProgramme.where(team:)) log_destroy(Team.where(id: team.id)) diff --git a/app/lib/generic_clinic_factory.rb b/app/lib/generic_clinic_factory.rb index 344b9bcd4b..84b2d2f1b8 100644 --- a/app/lib/generic_clinic_factory.rb +++ b/app/lib/generic_clinic_factory.rb @@ -35,10 +35,9 @@ def subteam end def location - team.locations.find_by(ods_code: team.ods_code, type: :generic_clinic) || + team.locations.find_by(type: :generic_clinic) || Location.create!( name: "Community clinic", - ods_code: team.ods_code, subteam:, type: :generic_clinic ) diff --git a/app/models/location.rb b/app/models/location.rb index 546ebbb238..83ea4d8a1c 100644 --- a/app/models/location.rb +++ b/app/models/location.rb @@ -84,7 +84,7 @@ class Location < ApplicationRecord end with_options if: :generic_clinic? do - validates :ods_code, inclusion: { in: :team_ods_code } + validates :ods_code, absence: true validates :subteam, presence: true end diff --git a/db/migrate/20250723194813_remove_ods_code_from_generic_clinics.rb b/db/migrate/20250723194813_remove_ods_code_from_generic_clinics.rb new file mode 100644 index 0000000000..b5614f7d06 --- /dev/null +++ b/db/migrate/20250723194813_remove_ods_code_from_generic_clinics.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class RemoveODSCodeFromGenericClinics < ActiveRecord::Migration[8.0] + def change + Location.generic_clinic.update_all(ods_code: nil) + end +end diff --git a/spec/factories/locations.rb b/spec/factories/locations.rb index 9bbe569b02..cf157cfa65 100644 --- a/spec/factories/locations.rb +++ b/spec/factories/locations.rb @@ -68,8 +68,6 @@ name { "Community clinic" } year_groups { (0..11).to_a } - - ods_code { subteam&.team&.ods_code } end factory :gp_practice do diff --git a/spec/models/location_spec.rb b/spec/models/location_spec.rb index 37a79c8097..3a6ee72b2b 100644 --- a/spec/models/location_spec.rb +++ b/spec/models/location_spec.rb @@ -76,14 +76,7 @@ it { should_not validate_presence_of(:gias_establishment_number) } it { should_not validate_presence_of(:gias_local_authority_code) } - it { should_not validate_presence_of(:ods_code) } - it { should validate_uniqueness_of(:ods_code).ignoring_case_sensitivity } - - it do - expect(location).to validate_inclusion_of(:ods_code).in_array( - [team.ods_code] - ) - end + it { should validate_absence_of(:ods_code) } it { should_not validate_presence_of(:urn) } it { should validate_uniqueness_of(:urn) } From b3743e1efc676ca4a3ead3ae8dac12907470f1af Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 23 Jul 2025 21:38:15 +0100 Subject: [PATCH 08/58] Create Organisation This adds a new model that groups together multiple teams under the same ODS code. For now this model is quite simple, but in the future we may expand it with more columns. Jira-Issue: MAV-1280 --- app/models/concerns/ods_code_concern.rb | 2 +- app/models/location.rb | 6 ++-- app/models/organisation.rb | 25 +++++++++++++ app/models/team.rb | 17 +++++---- .../20250723202655_create_organisations.rb | 36 +++++++++++++++++++ db/schema.rb | 12 +++++-- docs/managing-teams.md | 4 ++- spec/factories/organisations.rb | 22 ++++++++++++ spec/factories/teams.rb | 18 +++++++--- spec/models/organisation_spec.rb | 31 ++++++++++++++++ spec/models/team_spec.rb | 18 ++++++---- 11 files changed, 166 insertions(+), 25 deletions(-) create mode 100644 app/models/organisation.rb create mode 100644 db/migrate/20250723202655_create_organisations.rb create mode 100644 spec/factories/organisations.rb create mode 100644 spec/models/organisation_spec.rb diff --git a/app/models/concerns/ods_code_concern.rb b/app/models/concerns/ods_code_concern.rb index 6ddab59b65..81b9cf5b78 100644 --- a/app/models/concerns/ods_code_concern.rb +++ b/app/models/concerns/ods_code_concern.rb @@ -6,6 +6,6 @@ module ODSCodeConcern included do validates :ods_code, uniqueness: true, allow_nil: true - normalizes :ods_code, with: -> { _1.blank? ? nil : _1.upcase.strip } + normalizes :ods_code, with: -> { it.blank? ? nil : it.upcase.strip } end end diff --git a/app/models/location.rb b/app/models/location.rb index 83ea4d8a1c..3439fdd594 100644 --- a/app/models/location.rb +++ b/app/models/location.rb @@ -80,7 +80,7 @@ class Location < ApplicationRecord validates :urn, uniqueness: true, allow_nil: true with_options if: :community_clinic? do - validates :ods_code, exclusion: { in: :team_ods_code } + validates :ods_code, exclusion: { in: :organisation_ods_code } end with_options if: :generic_clinic? do @@ -133,7 +133,9 @@ def create_default_programme_year_groups!(programmes) private - def team_ods_code = [subteam&.team&.ods_code].compact + def organisation_ods_code + [subteam&.team&.organisation&.ods_code].compact + end def fhir_mapper @fhir_mapper ||= FHIRMapper::Location.new(self) diff --git a/app/models/organisation.rb b/app/models/organisation.rb new file mode 100644 index 0000000000..b4067c1687 --- /dev/null +++ b/app/models/organisation.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: organisations +# +# id :bigint not null, primary key +# ods_code :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_organisations_on_ods_code (ods_code) UNIQUE +# +class Organisation < ApplicationRecord + include ODSCodeConcern + + audited + has_associated_audits + + has_many :teams + + validates :ods_code, presence: true +end diff --git a/app/models/team.rb b/app/models/team.rb index d62f81841d..92393f8989 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -11,26 +11,30 @@ # days_before_invitations :integer default(21), not null # email :string # name :text not null -# ods_code :string not null # phone :string # phone_instructions :string # privacy_notice_url :string not null # privacy_policy_url :string not null # created_at :datetime not null # updated_at :datetime not null +# organisation_id :bigint not null # reply_to_id :uuid # # Indexes # -# index_teams_on_name (name) UNIQUE -# index_teams_on_ods_code (ods_code) UNIQUE +# index_teams_on_name (name) UNIQUE +# index_teams_on_organisation_id (organisation_id) +# +# Foreign Keys +# +# fk_rails_... (organisation_id => organisations.id) # class Team < ApplicationRecord - include ODSCodeConcern - - audited + audited associated_with: :organisation has_associated_audits + belongs_to :organisation + has_many :batches has_many :cohort_imports has_many :consent_forms @@ -64,7 +68,6 @@ class Team < ApplicationRecord validates :careplus_venue_code, presence: true validates :email, notify_safe_email: true validates :name, presence: true, uniqueness: true - validates :ods_code, presence: true, uniqueness: true validates :phone, presence: true, phone: true validates :privacy_notice_url, presence: true validates :privacy_policy_url, presence: true diff --git a/db/migrate/20250723202655_create_organisations.rb b/db/migrate/20250723202655_create_organisations.rb new file mode 100644 index 0000000000..62eb39f949 --- /dev/null +++ b/db/migrate/20250723202655_create_organisations.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class CreateOrganisations < ActiveRecord::Migration[8.0] + def up + create_table :organisations do |t| + t.string :ods_code, null: false + t.index :ods_code, unique: true + t.timestamps + end + + add_reference :teams, :organisation, foreign_key: true + + Team.find_each do |team| + organisation = Organisation.create!(ods_code: team.ods_code) + team.update_column(:organisation_id, organisation.id) + end + + change_table :teams, bulk: true do |t| + t.remove :ods_code + t.change_null :organisation_id, false + end + end + + def down + add_column :teams, :ods_code, :string + + Team.find_each do |team| + organisation = Organisation.find(team.organisation_id) + team.update_column(:ods_code, organisation.ods_code) + end + + remove_reference :teams, :organisation + + drop_table :organisations + end +end diff --git a/db/schema.rb b/db/schema.rb index c0e64a9491..11d0fc5afb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -511,6 +511,13 @@ t.datetime "updated_at", null: false end + create_table "organisations", force: :cascade do |t| + t.string "ods_code", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["ods_code"], name: "index_organisations_on_ods_code", unique: true + end + create_table "parent_relationships", force: :cascade do |t| t.bigint "parent_id", null: false t.bigint "patient_id", null: false @@ -760,7 +767,6 @@ t.datetime "updated_at", null: false t.string "email" t.string "privacy_policy_url", null: false - t.string "ods_code", null: false t.uuid "reply_to_id" t.string "phone" t.integer "days_before_consent_requests", default: 21, null: false @@ -769,8 +775,9 @@ t.string "careplus_venue_code", null: false t.string "privacy_notice_url", null: false t.string "phone_instructions" + t.bigint "organisation_id", null: false t.index ["name"], name: "index_teams_on_name", unique: true - t.index ["ods_code"], name: "index_teams_on_ods_code", unique: true + t.index ["organisation_id"], name: "index_teams_on_organisation_id" end create_table "teams_users", id: false, force: :cascade do |t| @@ -987,6 +994,7 @@ add_foreign_key "subteams", "teams" add_foreign_key "team_programmes", "programmes" add_foreign_key "team_programmes", "teams" + add_foreign_key "teams", "organisations" add_foreign_key "triage", "patients" add_foreign_key "triage", "programmes" add_foreign_key "triage", "teams" diff --git a/docs/managing-teams.md b/docs/managing-teams.md index 63cac01a37..9b71611903 100644 --- a/docs/managing-teams.md +++ b/docs/managing-teams.md @@ -10,10 +10,12 @@ When first onboarding a new SAIS team, there’s a lot of information to include ```yaml organisation: + ods_code: # ODS code of the organisation + +team: name: # Unique name of the organisation email: # Contact email address phone: # Contact phone number - ods_code: # Unique ODS code careplus_venue_code: # Venue code used in CarePlus exports privacy_notice_url: # URL of a privacy notice shown to parents privacy_policy_url: # URL of a privacy policy shown to parents diff --git a/spec/factories/organisations.rb b/spec/factories/organisations.rb new file mode 100644 index 0000000000..d9f507a725 --- /dev/null +++ b/spec/factories/organisations.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: organisations +# +# id :bigint not null, primary key +# ods_code :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_organisations_on_ods_code (ods_code) UNIQUE +# +FactoryBot.define do + factory :organisation do + transient { sequence(:identifier) } + + ods_code { "U#{identifier}" } + end +end diff --git a/spec/factories/teams.rb b/spec/factories/teams.rb index 81259706ae..c94d60b15e 100644 --- a/spec/factories/teams.rb +++ b/spec/factories/teams.rb @@ -11,28 +11,36 @@ # days_before_invitations :integer default(21), not null # email :string # name :text not null -# ods_code :string not null # phone :string # phone_instructions :string # privacy_notice_url :string not null # privacy_policy_url :string not null # created_at :datetime not null # updated_at :datetime not null +# organisation_id :bigint not null # reply_to_id :uuid # # Indexes # -# index_teams_on_name (name) UNIQUE -# index_teams_on_ods_code (ods_code) UNIQUE +# index_teams_on_name (name) UNIQUE +# index_teams_on_organisation_id (organisation_id) +# +# Foreign Keys +# +# fk_rails_... (organisation_id => organisations.id) # FactoryBot.define do factory :team do - transient { sequence(:identifier) } + transient do + sequence(:identifier) + ods_code { "U#{identifier}" } + end + + organisation { association(:organisation, ods_code:) } name { "SAIS Team #{identifier}" } email { "sais-team-#{identifier}@example.com" } phone { "01234 567890" } - ods_code { "U#{identifier}" } careplus_venue_code { identifier.to_s } privacy_notice_url { "https://example.com/privacy-notice" } privacy_policy_url { "https://example.com/privacy-policy" } diff --git a/spec/models/organisation_spec.rb b/spec/models/organisation_spec.rb new file mode 100644 index 0000000000..64dab27fcf --- /dev/null +++ b/spec/models/organisation_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: organisations +# +# id :bigint not null, primary key +# ods_code :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_organisations_on_ods_code (ods_code) UNIQUE +# +describe Organisation do + subject(:organisation) { build(:organisation) } + + describe "associations" do + it { should have_many(:teams) } + end + + describe "normalizations" do + it { should normalize(:ods_code).from(" r1a ").to("R1A") } + end + + describe "validations" do + it { should validate_presence_of(:ods_code) } + it { should validate_uniqueness_of(:ods_code).ignoring_case_sensitivity } + end +end diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb index 52a92c5cb0..c0ff707885 100644 --- a/spec/models/team_spec.rb +++ b/spec/models/team_spec.rb @@ -11,37 +11,41 @@ # days_before_invitations :integer default(21), not null # email :string # name :text not null -# ods_code :string not null # phone :string # phone_instructions :string # privacy_notice_url :string not null # privacy_policy_url :string not null # created_at :datetime not null # updated_at :datetime not null +# organisation_id :bigint not null # reply_to_id :uuid # # Indexes # -# index_teams_on_name (name) UNIQUE -# index_teams_on_ods_code (ods_code) UNIQUE +# index_teams_on_name (name) UNIQUE +# index_teams_on_organisation_id (organisation_id) +# +# Foreign Keys +# +# fk_rails_... (organisation_id => organisations.id) # describe Team do subject(:team) { build(:team) } + describe "associations" do + it { should belong_to(:organisation) } + end + describe "validations" do it { should validate_presence_of(:email) } it { should validate_presence_of(:name) } - it { should validate_presence_of(:ods_code) } it { should validate_presence_of(:phone) } it { should validate_presence_of(:privacy_policy_url) } it { should validate_uniqueness_of(:name) } - it { should validate_uniqueness_of(:ods_code).ignoring_case_sensitivity } end - it { should normalize(:ods_code).from(" r1a ").to("R1A") } - it_behaves_like "a model with a normalised email address" it_behaves_like "a model with a normalised phone number" From ccb43388b1bd2cf116cc7df24cb7e8cf694f55fb Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 23 Jul 2025 22:07:36 +0100 Subject: [PATCH 09/58] Update references to team ODS code The ODS code no longer belongs on each team as some times will share the same ODS code in the future. Instead, the ODS code can be found on the related organisation model. For now, there's no longer a unique identifier on each team. We will need to add one to support logging in where we need to know which team a user belongs to through their CIS2 workgroup. At the moment we assume that each organisation only ever has one team, but this won't always be the case and needs to be supported in a follow up pull request. Jira-Issue: MAV-1280 --- .../api/testing/teams_controller.rb | 15 ++++++-- .../concerns/authentication_concern.rb | 4 ++- .../concerns/triage_mailer_concern.rb | 10 +++--- .../patient_sessions/programmes_controller.rb | 2 +- .../users/omniauth_callbacks_controller.rb | 12 +++++-- app/forms/select_team_form.rb | 6 ++-- app/forms/vaccinate_form.rb | 4 +-- .../fhir_mapper/{team.rb => organisation.rb} | 14 ++++---- app/lib/fhir_mapper/vaccination_record.rb | 2 +- app/lib/mavis_cli/clinics/add_to_team.rb | 8 ++++- app/lib/mavis_cli/generate/consents.rb | 4 ++- .../mavis_cli/generate/vaccination_records.rb | 4 ++- app/lib/mavis_cli/schools/add_to_team.rb | 3 +- app/lib/mavis_cli/teams/add_programme.rb | 17 +++++++-- app/lib/mavis_cli/teams/create_sessions.rb | 13 +++++-- app/lib/reports/offline_session_exporter.rb | 4 +-- .../programme_vaccinations_exporter.rb | 4 ++- app/models/immunisation_import_row.rb | 8 +++-- app/models/onboarding.rb | 25 ++++++++++--- app/models/organisation.rb | 12 +++++++ app/models/patient_session.rb | 3 +- app/models/session.rb | 1 + app/models/session_notification.rb | 6 ++-- app/models/team.rb | 10 ------ app/models/user.rb | 18 ++++++---- app/models/vaccination_record.rb | 5 ++- app/policies/patient_policy.rb | 3 +- app/policies/vaccination_record_policy.rb | 5 +-- app/views/users/teams/new.html.erb | 4 +-- db/seeds.rb | 3 +- docs/ops-tasks.md | 6 ++-- lib/generate/cohort_imports.rb | 12 ++++--- lib/tasks/subteams.rake | 3 +- lib/tasks/teams.rake | 8 ++++- lib/tasks/users.rake | 8 ++++- script/generate_model_office_data.rb | 2 +- spec/factories/users.rb | 2 +- spec/factories/vaccination_records.rb | 5 +-- spec/features/cli_generate_consents_spec.rb | 2 +- .../cli_generate_vaccination_records_spec.rb | 2 +- spec/features/cli_teams_add_programme_spec.rb | 33 +++++++++++++---- .../cli_teams_create_sessions_spec.rb | 36 +++++++++++++++---- ...is2_authentication_from_start_page_spec.rb | 2 +- ..._cis2_authentication_with_redirect_spec.rb | 2 +- ...is2_authentication_with_wrong_role_spec.rb | 8 +++-- ...uthentication_with_wrong_workgroup_spec.rb | 2 +- spec/fixtures/files/onboarding/valid.yaml | 4 ++- spec/forms/patient_search_form_spec.rb | 2 +- spec/lib/fhir_mapper/organisation_spec.rb | 20 +++++++++++ spec/lib/fhir_mapper/team_spec.rb | 32 ----------------- .../fhir_mapper/vaccination_record_spec.rb | 19 +++++----- .../reports/offline_session_exporter_spec.rb | 26 +++++++++----- .../programme_vaccinations_exporter_spec.rb | 7 ++-- spec/models/immunisation_import_row_spec.rb | 4 +-- spec/models/location_spec.rb | 2 +- spec/models/onboarding_spec.rb | 8 +++-- spec/policies/patient_policy_spec.rb | 2 +- spec/requests/api/testing/onboard_spec.rb | 2 +- spec/support/cis2_auth_helper.rb | 4 +-- 59 files changed, 326 insertions(+), 168 deletions(-) rename app/lib/fhir_mapper/{team.rb => organisation.rb} (63%) create mode 100644 spec/lib/fhir_mapper/organisation_spec.rb delete mode 100644 spec/lib/fhir_mapper/team_spec.rb diff --git a/app/controllers/api/testing/teams_controller.rb b/app/controllers/api/testing/teams_controller.rb index 52d31185b8..bf67223ebb 100644 --- a/app/controllers/api/testing/teams_controller.rb +++ b/app/controllers/api/testing/teams_controller.rb @@ -8,7 +8,14 @@ def destroy response.headers["Cache-Control"] = "no-cache" keep_itself = ActiveModel::Type::Boolean.new.cast(params[:keep_itself]) - team = Team.find_by!(ods_code: params[:ods_code]) + + # TODO: Select the right team based on an identifier. + team = + Team.joins(:organisation).find_by!( + organisation: { + ods_code: params[:ods_code] + } + ) @start_time = Time.zone.now @@ -55,7 +62,11 @@ def destroy log_destroy(VaccinationRecord.where(batch: batches)) log_destroy(batches) - log_destroy(VaccinationRecord.where(performed_ods_code: team.ods_code)) + log_destroy( + VaccinationRecord.where( + performed_ods_code: team.organisation.ods_code + ) + ) unless keep_itself log_destroy(SessionProgramme.where(session: sessions)) diff --git a/app/controllers/concerns/authentication_concern.rb b/app/controllers/concerns/authentication_concern.rb index c51b1f5809..9dbb0c01ec 100644 --- a/app/controllers/concerns/authentication_concern.rb +++ b/app/controllers/concerns/authentication_concern.rb @@ -32,7 +32,9 @@ def cis2_session? end def selected_cis2_org_is_registered? - Team.exists?(ods_code: session["cis2_info"]["selected_org"]["code"]) + Organisation.exists?( + ods_code: session["cis2_info"]["selected_org"]["code"] + ) end def selected_cis2_workgroup_is_valid? diff --git a/app/controllers/concerns/triage_mailer_concern.rb b/app/controllers/concerns/triage_mailer_concern.rb index cc81025883..aac6b55b61 100644 --- a/app/controllers/concerns/triage_mailer_concern.rb +++ b/app/controllers/concerns/triage_mailer_concern.rb @@ -10,7 +10,6 @@ def send_triage_confirmation(patient_session, programme, consent) session = patient_session.session patient = patient_session.patient - team = patient_session.team return unless patient.send_notifications? return if consent.via_self_consent? @@ -23,7 +22,10 @@ def send_triage_confirmation(patient_session, programme, consent) EmailDeliveryJob.perform_later(:triage_vaccination_wont_happen, **params) elsif vaccination_at_clinic?(patient, programme, consent) email_template = - resolve_email_template(:triage_vaccination_at_clinic, team) + resolve_email_template( + :triage_vaccination_at_clinic, + patient_session.organisation + ) EmailDeliveryJob.perform_later(email_template, **params) elsif consent.requires_triage? EmailDeliveryJob.perform_later(:consent_confirmation_triage, **params) @@ -68,9 +70,9 @@ def vaccination_at_clinic?(patient, programme, consent) ).delay_vaccination? end - def resolve_email_template(template_name, team) + def resolve_email_template(template_name, organisation) template_names = [ - :"#{template_name}_#{team.ods_code.downcase}", + :"#{template_name}_#{organisation.ods_code.downcase}", template_name ] template_names.find { GOVUK_NOTIFY_EMAIL_TEMPLATES.key?(it) } diff --git a/app/controllers/patient_sessions/programmes_controller.rb b/app/controllers/patient_sessions/programmes_controller.rb index 66a951b1f5..482ac048aa 100644 --- a/app/controllers/patient_sessions/programmes_controller.rb +++ b/app/controllers/patient_sessions/programmes_controller.rb @@ -25,7 +25,7 @@ def record_already_vaccinated patient: @patient, performed_at: Time.current, performed_by_user_id: current_user.id, - performed_ods_code: current_team.ods_code, + performed_ods_code: current_team.organisation.ods_code, programme: @programme, session: @session ) diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index 218f62dcb5..ffda59c361 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -21,7 +21,7 @@ def cis2 elsif !selected_cis2_org_is_registered? redirect_to users_team_not_found_path else - @user = User.find_or_create_from_cis2_oidc(user_cis2_info, team) + @user = User.find_or_create_from_cis2_oidc(user_cis2_info, teams) # Force is set to true because the `session_token` might have changed # even if the same user is logging in. @@ -92,8 +92,14 @@ def raw_cis2_info user_cis2_info["extra"]["raw_info"] end - def team - @team ||= Team.find_by(ods_code: selected_cis2_org["org_code"]) + def organisation + @organisation ||= + Organisation.find_by(ods_code: selected_cis2_org["org_code"]) + end + + def teams + # TODO: Select the right team based on the user's workgroup. + organisation.teams end def set_cis2_session_info diff --git a/app/forms/select_team_form.rb b/app/forms/select_team_form.rb index 884d00f93b..e4d2326a60 100644 --- a/app/forms/select_team_form.rb +++ b/app/forms/select_team_form.rb @@ -16,7 +16,7 @@ def save request_session["cis2_info"] = { "selected_org" => { "name" => team.name, - "code" => team.ods_code + "code" => organisation.ods_code }, "selected_role" => { "code" => User::CIS2_NURSE_ROLE, @@ -29,7 +29,9 @@ def save private - def team = current_user.teams.find(team_id) + def team = current_user.teams.includes(:organisation).find(team_id) + + delegate :organisation, to: :team def team_id_values = current_user.teams.pluck(:id) end diff --git a/app/forms/vaccinate_form.rb b/app/forms/vaccinate_form.rb index 83f791218c..4be8bb31a4 100644 --- a/app/forms/vaccinate_form.rb +++ b/app/forms/vaccinate_form.rb @@ -91,7 +91,7 @@ def save(draft_vaccination_record:) draft_vaccination_record.patient_id = patient_session.patient_id draft_vaccination_record.performed_at = Time.current draft_vaccination_record.performed_by_user = current_user - draft_vaccination_record.performed_ods_code = team.ods_code + draft_vaccination_record.performed_ods_code = organisation.ods_code draft_vaccination_record.programme = programme draft_vaccination_record.session_id = patient_session.session_id @@ -100,7 +100,7 @@ def save(draft_vaccination_record:) private - delegate :team, to: :patient_session + delegate :organisation, to: :patient_session def administered? = vaccine_method != "none" diff --git a/app/lib/fhir_mapper/team.rb b/app/lib/fhir_mapper/organisation.rb similarity index 63% rename from app/lib/fhir_mapper/team.rb rename to app/lib/fhir_mapper/organisation.rb index 4641a5c1dd..ad23fa3848 100644 --- a/app/lib/fhir_mapper/team.rb +++ b/app/lib/fhir_mapper/organisation.rb @@ -1,11 +1,9 @@ # frozen_string_literal: true module FHIRMapper - class Team - delegate :ods_code, :name, :type, to: :@team - - def initialize(team) - @team = team + class Organisation + def initialize(organisation) + @organisation = organisation end def self.fhir_reference(ods_code:) @@ -19,8 +17,8 @@ def self.fhir_reference(ods_code:) ) end - def fhir_reference - self.class.fhir_reference(ods_code: ods_code) - end + def fhir_reference = self.class.fhir_reference(ods_code:) + + delegate :ods_code, to: :@organisation end end diff --git a/app/lib/fhir_mapper/vaccination_record.rb b/app/lib/fhir_mapper/vaccination_record.rb index 642e593447..6acbce3846 100644 --- a/app/lib/fhir_mapper/vaccination_record.rb +++ b/app/lib/fhir_mapper/vaccination_record.rb @@ -121,7 +121,7 @@ def fhir_user_performer(reference_id:) def fhir_org_performer FHIR::Immunization::Performer.new( - actor: Team.fhir_reference(ods_code: performed_ods_code) + actor: Organisation.fhir_reference(ods_code: performed_ods_code) ) end diff --git a/app/lib/mavis_cli/clinics/add_to_team.rb b/app/lib/mavis_cli/clinics/add_to_team.rb index f774d112ea..08adf5d3c4 100644 --- a/app/lib/mavis_cli/clinics/add_to_team.rb +++ b/app/lib/mavis_cli/clinics/add_to_team.rb @@ -15,7 +15,13 @@ class AddToTeam < Dry::CLI::Command def call(team_ods_code:, subteam:, clinic_ods_codes:, **) MavisCLI.load_rails - team = Team.find_by(ods_code: team_ods_code) + # TODO: Select the right team based on an identifier. + team = + Team.joins(:organisation).find_by( + organisation: { + ods_code: team_ods_code + } + ) if team.nil? warn "Could not find team." diff --git a/app/lib/mavis_cli/generate/consents.rb b/app/lib/mavis_cli/generate/consents.rb index 6a03359941..09f4350b98 100644 --- a/app/lib/mavis_cli/generate/consents.rb +++ b/app/lib/mavis_cli/generate/consents.rb @@ -46,7 +46,9 @@ def call( session = Session.find(session_id) if session_id ::Generate::Consents.call( - team: Team.find_by(ods_code: team), + # TODO: Select the right team based on an identifier. + team: + Team.joins(:organisation).find_by(organisation: { ods_code: team }), programme: Programme.find_by(type: programme_type), session:, given: given.to_i, diff --git a/app/lib/mavis_cli/generate/vaccination_records.rb b/app/lib/mavis_cli/generate/vaccination_records.rb index c5daebfdef..70bae1b38e 100644 --- a/app/lib/mavis_cli/generate/vaccination_records.rb +++ b/app/lib/mavis_cli/generate/vaccination_records.rb @@ -29,7 +29,9 @@ def call(team:, programme_type:, administered:, session_id: nil, **) session = Session.find(session_id) if session_id ::Generate::VaccinationRecords.call( - team: Team.find_by(ods_code: team), + # TODO: Select the right team based on an identifier. + team: + Team.joins(:organisation).find_by(organisation: { ods_code: team }), programme: Programme.includes(:teams).find_by(type: programme_type), session:, administered: administered.to_i diff --git a/app/lib/mavis_cli/schools/add_to_team.rb b/app/lib/mavis_cli/schools/add_to_team.rb index f74d697627..eb8ae6c7aa 100644 --- a/app/lib/mavis_cli/schools/add_to_team.rb +++ b/app/lib/mavis_cli/schools/add_to_team.rb @@ -19,7 +19,8 @@ class AddToTeam < Dry::CLI::Command def call(ods_code:, subteam:, urns:, programmes: [], **) MavisCLI.load_rails - team = Team.find_by(ods_code:) + # TODO: Select the right team based on an identifier. + team = Team.joins(:organisation).find_by(organisation: { ods_code: }) if team.nil? warn "Could not find team." diff --git a/app/lib/mavis_cli/teams/add_programme.rb b/app/lib/mavis_cli/teams/add_programme.rb index 915dd4b8d2..46959f9540 100644 --- a/app/lib/mavis_cli/teams/add_programme.rb +++ b/app/lib/mavis_cli/teams/add_programme.rb @@ -5,14 +5,25 @@ module Teams class AddProgramme < Dry::CLI::Command desc "Adds a programme to a team" - argument :ods_code, required: true, desc: "The ODS code of the team" + argument :ods_code, + required: true, + desc: "The ODS code of the organisation" + + argument :name, required: true, desc: "The name of the team" argument :type, required: true, desc: "The type of programme to add" - def call(ods_code:, type:) + def call(ods_code:, name:, type:) MavisCLI.load_rails - team = Team.find_by(ods_code:) + organisation = Organisation.find_by(ods_code:) + + if organisation.nil? + warn "Could not find organisation." + return + end + + team = organisation.teams.find_by(name:) if team.nil? warn "Could not find team." diff --git a/app/lib/mavis_cli/teams/create_sessions.rb b/app/lib/mavis_cli/teams/create_sessions.rb index 2865dd7e03..d50416e987 100644 --- a/app/lib/mavis_cli/teams/create_sessions.rb +++ b/app/lib/mavis_cli/teams/create_sessions.rb @@ -9,14 +9,23 @@ class CreateSessions < Dry::CLI::Command required: true, desc: "The ODS code of the organisation" + argument :name, required: true, desc: "The name of the team" + option :academic_year, type: :integer, desc: "The academic year to create the sessions for" - def call(ods_code:, academic_year: nil) + def call(ods_code:, name:, academic_year: nil) MavisCLI.load_rails - team = Team.find_by(ods_code:) + organisation = Organisation.find_by(ods_code:) + + if organisation.nil? + warn "Could not find organisation." + return + end + + team = organisation.teams.find_by(name:) if team.nil? warn "Could not find team." diff --git a/app/lib/reports/offline_session_exporter.rb b/app/lib/reports/offline_session_exporter.rb index bae2018278..c06ae14961 100644 --- a/app/lib/reports/offline_session_exporter.rb +++ b/app/lib/reports/offline_session_exporter.rb @@ -34,7 +34,7 @@ def self.call(...) = new(...).call attr_reader :session - delegate :academic_year, :location, :team, to: :session + delegate :academic_year, :location, :organisation, :team, to: :session def add_vaccinations_sheet(package) workbook = package.workbook @@ -249,7 +249,7 @@ def add_patient_cells(row, patient_session:, programme:) triage = triages.dig(patient.id, programme.id) academic_year = session.academic_year - row[:organisation_code] = team.ods_code + row[:organisation_code] = organisation.ods_code row[:person_forename] = patient.given_name row[:person_surname] = patient.family_name row[:person_dob] = patient.date_of_birth diff --git a/app/lib/reports/programme_vaccinations_exporter.rb b/app/lib/reports/programme_vaccinations_exporter.rb index bafe28c68e..ec35e06a7e 100644 --- a/app/lib/reports/programme_vaccinations_exporter.rb +++ b/app/lib/reports/programme_vaccinations_exporter.rb @@ -27,6 +27,8 @@ def self.call(...) = new(...).call attr_reader :team, :programme, :academic_year, :start_date, :end_date + delegate :organisation, to: :team + def headers %w[ ORGANISATION_CODE @@ -195,7 +197,7 @@ def row(vaccination_record:) academic_year = session.academic_year [ - team.ods_code, + organisation.ods_code, school_urn(location:, patient:), school_name(location:, patient:), care_setting(location:), diff --git a/app/models/immunisation_import_row.rb b/app/models/immunisation_import_row.rb index 7867290fee..dd324f327d 100644 --- a/app/models/immunisation_import_row.rb +++ b/app/models/immunisation_import_row.rb @@ -223,6 +223,8 @@ def vaccine_name = @data[:vaccine_given] private + delegate :organisation, to: :team + def location_name return unless session.nil? || session.location.generic_clinic? @@ -800,11 +802,11 @@ def validate_performed_ods_code if performed_ods_code.nil? errors.add(:base, "ORGANISATION_CODE is required") elsif performed_ods_code.blank? - errors.add(performed_ods_code.header, "Enter a team code.") - elsif performed_ods_code.to_s != team.ods_code + errors.add(performed_ods_code.header, "Enter an organisation code.") + elsif performed_ods_code.to_s != organisation.ods_code errors.add( performed_ods_code.header, - "Enter a team code that matches the current team." + "Enter an organisation code that matches the current team." ) end end diff --git a/app/models/onboarding.rb b/app/models/onboarding.rb index d0d5ba2183..59f67ac50f 100644 --- a/app/models/onboarding.rb +++ b/app/models/onboarding.rb @@ -14,6 +14,8 @@ class Onboarding year_groups ].freeze + ORGANISATION_ATTRIBUTES = %i[ods_code].freeze + TEAM_ATTRIBUTES = %i[ careplus_venue_code days_before_consent_reminders @@ -21,7 +23,6 @@ class Onboarding days_before_invitations email name - ods_code phone phone_instructions privacy_notice_url @@ -54,7 +55,16 @@ class Onboarding def initialize(hash) config = hash.deep_symbolize_keys - @team = Team.new(config.fetch(:team, {}).slice(*TEAM_ATTRIBUTES)) + @organisation = + Organisation.find_or_initialize_by( + config.fetch(:organisation, {}).slice(*ORGANISATION_ATTRIBUTES) + ) + + @team = + Team.new( + **config.fetch(:team, {}).slice(*TEAM_ATTRIBUTES), + organisation: @organisation + ) @programmes = config @@ -110,6 +120,7 @@ def invalid?(context = nil) def errors super.tap do |errors| + merge_errors_from([organisation], errors:, name: "organisation") merge_errors_from([team], errors:, name: "team") merge_errors_from(programmes, errors:, name: "programme") merge_errors_from(subteams, errors:, name: "subteam") @@ -138,12 +149,18 @@ def save!(create_sessions_for_previous_academic_year: false) private - attr_reader :team, :programmes, :subteams, :users, :schools, :clinics + attr_reader :organisation, + :team, + :programmes, + :subteams, + :users, + :schools, + :clinics def academic_year = AcademicYear.pending def models - [team] + programmes + subteams + users + schools + clinics + [organisation] + [team] + programmes + subteams + users + schools + clinics end def merge_errors_from(objects, errors:, name:) diff --git a/app/models/organisation.rb b/app/models/organisation.rb index b4067c1687..f798a58a18 100644 --- a/app/models/organisation.rb +++ b/app/models/organisation.rb @@ -22,4 +22,16 @@ class Organisation < ApplicationRecord has_many :teams validates :ods_code, presence: true + + delegate :fhir_reference, to: :fhir_mapper + + class << self + delegate :fhir_reference, to: FHIRMapper::Organisation + end + + private + + def fhir_mapper + @fhir_mapper ||= FHIRMapper::Organisation.new(self) + end end diff --git a/app/models/patient_session.rb b/app/models/patient_session.rb index 82d98f818c..65d91c5d7d 100644 --- a/app/models/patient_session.rb +++ b/app/models/patient_session.rb @@ -46,13 +46,14 @@ class PatientSession < ApplicationRecord has_many :gillick_assessments has_many :pre_screenings + has_many :session_attendances, dependent: :destroy has_many :session_statuses has_one :registration_status has_one :location, through: :session has_one :subteam, through: :session has_one :team, through: :session - has_many :session_attendances, dependent: :destroy + has_one :organisation, through: :team has_many :notes, -> { where(session_id: it.session_id) }, through: :patient diff --git a/app/models/session.rb b/app/models/session.rb index b0131b1e54..428d30f7cb 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -44,6 +44,7 @@ class Session < ApplicationRecord has_and_belongs_to_many :immunisation_imports + has_one :organisation, through: :team has_one :subteam, through: :location has_many :programmes, through: :session_programmes has_many :gillick_assessments, through: :patient_sessions diff --git a/app/models/session_notification.rb b/app/models/session_notification.rb index dcf9ae3c6c..f9a61bc544 100644 --- a/app/models/session_notification.rb +++ b/app/models/session_notification.rb @@ -108,7 +108,7 @@ def self.create_and_send!( sent_by: current_user } - template_name = compute_template_name(type, session.team) + template_name = compute_template_name(type, session.organisation) EmailDeliveryJob.perform_later(template_name, **params) @@ -118,9 +118,9 @@ def self.create_and_send!( end end - def self.compute_template_name(type, team) + def self.compute_template_name(type, organisation) template_names = [ - :"session_#{type}_#{team.ods_code.downcase}", + :"session_#{type}_#{organisation.ods_code.downcase}", :"session_#{type}" ] diff --git a/app/models/team.rb b/app/models/team.rb index 92393f8989..67a1c8d20f 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -72,12 +72,6 @@ class Team < ApplicationRecord validates :privacy_notice_url, presence: true validates :privacy_policy_url, presence: true - delegate :fhir_reference, to: :fhir_mapper - - class << self - delegate :fhir_reference, to: FHIRMapper::Team - end - def year_groups @year_groups ||= location_programme_year_groups.pluck_year_groups end @@ -114,8 +108,4 @@ def weeks_before_invitations def weeks_before_invitations=(value) self.days_before_invitations = value * 7 end - - private - - def fhir_mapper = @fhir_mapper ||= FHIRMapper::Team.new(self) end diff --git a/app/models/user.rb b/app/models/user.rb index fecdc5b35e..3349001fbe 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -76,7 +76,7 @@ class User < ApplicationRecord delegate :fhir_practitioner, to: :fhir_mapper - def self.find_or_create_from_cis2_oidc(userinfo, team) + def self.find_or_create_from_cis2_oidc(userinfo, teams) user = User.find_or_initialize_by( provider: userinfo[:provider], @@ -93,21 +93,25 @@ def self.find_or_create_from_cis2_oidc(userinfo, team) ActiveRecord::Base.transaction do user.save! - user.teams << team unless user.teams.include?(team) + teams.each { |team| user.teams << team unless user.teams.include?(team) } user end end - def selected_team - @selected_team ||= + def selected_organisation + @selected_organisation ||= if cis2_info.present? - Team.includes(:programmes).find_by( - ods_code: cis2_info.dig("selected_org", "code") - ) + Organisation.find_by(ods_code: cis2_info.dig("selected_org", "code")) end end + def selected_team + # TODO: Select the right team based on the user's workgroup. + @selected_team ||= + Team.includes(:programmes).find_by(organisation: selected_organisation) + end + def requires_email_and_password? provider.blank? || uid.blank? end diff --git a/app/models/vaccination_record.rb b/app/models/vaccination_record.rb index 61be3d3ae0..64e7fbe453 100644 --- a/app/models/vaccination_record.rb +++ b/app/models/vaccination_record.rb @@ -95,6 +95,7 @@ class VaccinationRecord < ApplicationRecord has_one :identity_check, autosave: true, dependent: :destroy has_one :location, through: :session + has_one :organisation, through: :session has_one :team, through: :session has_one :subteam, through: :session @@ -219,7 +220,9 @@ def requires_location_name? delegate :maximum_dose_sequence, to: :programme - def fhir_mapper = @fhir_mapper ||= FHIRMapper::VaccinationRecord.new(self) + def fhir_mapper + @fhir_mapper ||= FHIRMapper::VaccinationRecord.new(self) + end def changes_need_to_be_synced_to_nhs_immunisations_api? saved_changes.present? && !saved_change_to_nhs_immunisations_api_etag? && diff --git a/app/policies/patient_policy.rb b/app/policies/patient_policy.rb index 31da327d7b..21fa356670 100644 --- a/app/policies/patient_policy.rb +++ b/app/policies/patient_policy.rb @@ -3,6 +3,7 @@ class PatientPolicy < ApplicationPolicy class Scope < ApplicationPolicy::Scope def resolve + organisation = user.selected_organisation team = user.selected_team return scope.none if team.nil? @@ -33,7 +34,7 @@ def resolve .or( VaccinationRecord.where( "vaccination_records.patient_id = patients.id" - ).where(performed_ods_code: team.ods_code) + ).where(performed_ods_code: organisation.ods_code) ) .arel .exists diff --git a/app/policies/vaccination_record_policy.rb b/app/policies/vaccination_record_policy.rb index b15278c5d0..529ac5220e 100644 --- a/app/policies/vaccination_record_policy.rb +++ b/app/policies/vaccination_record_policy.rb @@ -11,7 +11,7 @@ def new? def edit? user.is_nurse? && record.session_id.present? && - record.performed_ods_code == user.selected_team.ods_code + record.performed_ods_code == user.selected_organisation.ods_code end def update? @@ -24,6 +24,7 @@ def destroy? class Scope < ApplicationPolicy::Scope def resolve + organisation = user.selected_organisation team = user.selected_team return scope.none if team.nil? @@ -31,7 +32,7 @@ def resolve .kept .where(patient: team.patients) .or(scope.kept.where(session: team.sessions)) - .or(scope.kept.where(performed_ods_code: team.ods_code)) + .or(scope.kept.where(performed_ods_code: organisation.ods_code)) end end end diff --git a/app/views/users/teams/new.html.erb b/app/views/users/teams/new.html.erb index 3762cd2504..574f8cd8cd 100644 --- a/app/views/users/teams/new.html.erb +++ b/app/views/users/teams/new.html.erb @@ -6,10 +6,10 @@ <%= f.govuk_error_summary %> <%= f.govuk_collection_radio_buttons :team_id, - current_user.teams, + current_user.teams.includes(:organisation), :id, :name, - :ods_code, + -> { _1.organisation.ods_code }, legend: { text: legend, size: "xl", tag: "h1" } %> <%= f.govuk_submit %> diff --git a/db/seeds.rb b/db/seeds.rb index 9d55ed71b7..1fda2ecd73 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -20,7 +20,8 @@ def create_gp_practices end def create_team(ods_code:) - Team.find_by(ods_code:) || + # TODO: Select the right team based on an identifier. + Team.joins(:organisation).find_by(organisation: { ods_code: }) || FactoryBot.create( :team, :with_generic_clinic, diff --git a/docs/ops-tasks.md b/docs/ops-tasks.md index dde47b0b90..6f596bde15 100644 --- a/docs/ops-tasks.md +++ b/docs/ops-tasks.md @@ -5,7 +5,8 @@ If it's necessary to bulk remove patients from sessions (i.e. more than a few usages of "Remove from cohort" required), the following commands can be used in a Rails console: ```rb -org = Team.find_by(ods_code: "") +org = Organisation.find_by(ods_code: "") +team = org.teams.find_by(name: "") location = org.schools.find_by(name: "School name") session = org.sessions.find_by(location:) @@ -68,7 +69,8 @@ Consent.where(notify_parents_on_vaccination: false).pluck(:patient_id) ## Consent response stats per school ```rb -team = Team.find_by(ods_code: "...") +org = Organisation.find_by(ods_code: "...") +team = org.teams.find_by(name: "") dates = {} sessions = team.sessions diff --git a/lib/generate/cohort_imports.rb b/lib/generate/cohort_imports.rb index 810462a64e..0549e75fff 100644 --- a/lib/generate/cohort_imports.rb +++ b/lib/generate/cohort_imports.rb @@ -26,8 +26,9 @@ # # You can pull out the year groups with the following: # -# org = Team.find_by(ods_code: "A9A5A") -# org.locations.school.pluck(:urn, :year_groups) .to_h +# org = Organisation.find_by(ods_code: "A9A5A") +# team = org.teams.find_by(name: "") +# team.locations.school.pluck(:urn, :year_groups) .to_h # module Generate class CohortImports @@ -47,7 +48,8 @@ def initialize( patient_count: 10, progress_bar: nil ) - @team = Team.find_by(ods_code:) + # TODO: Select the right team based on an identifier. + @team = Team.joins(:organisation).find_by(organisation: { ods_code: }) @programme = Programme.find_by(type: programme) @urns = urns || @team.locations.select { it.urn.present? }.sample(3).pluck(:urn) @@ -69,9 +71,11 @@ def patients private + delegate :organisation, to: :team + def cohort_import_csv_filepath Rails.root.join( - "tmp/perf-test-cohort-import-#{team.ods_code}-#{programme.type}.csv" + "tmp/perf-test-cohort-import-#{organisation.ods_code}-#{programme.type}.csv" ) end diff --git a/lib/tasks/subteams.rake b/lib/tasks/subteams.rake index 8f256a3f47..cd73165c8b 100644 --- a/lib/tasks/subteams.rake +++ b/lib/tasks/subteams.rake @@ -28,7 +28,8 @@ namespace :subteams do end ActiveRecord::Base.transaction do - team = Team.find_by!(ods_code:) + # TODO: Select the right team based on an identifier. + team = Team.joins(:organisation).find_by!(organisation: { ods_code: }) subteam = team.subteams.create!(name:, email:, phone:) diff --git a/lib/tasks/teams.rake b/lib/tasks/teams.rake index 90f50a5119..9ced89d076 100644 --- a/lib/tasks/teams.rake +++ b/lib/tasks/teams.rake @@ -3,7 +3,13 @@ namespace :teams do desc "Add a programme to a team." task :add_programme, %i[ods_code type] => :environment do |_task, args| - team = Team.find_by!(ods_code: args[:ods_code]) + # TODO: Select the right team based on an identifier. + team = + Team.joins(:organisation).find_by!( + organisation: { + ods_code: args[:ods_code] + } + ) programme = Programme.find_by!(type: args[:type]) TeamProgramme.find_or_create_by!(team:, programme:) diff --git a/lib/tasks/users.rake b/lib/tasks/users.rake index b0220cfd49..e98a4a2b4c 100644 --- a/lib/tasks/users.rake +++ b/lib/tasks/users.rake @@ -32,7 +32,13 @@ namespace :users do raise "Expected 5-6 arguments, got #{args.to_a.size}" end - team = Team.find_by!(ods_code: team_ods_code) + # TODO: Select the right team based on an identifier. + team = + Team.joins(:organisation).find_by!( + organisation: { + ods_code: team_ods_code + } + ) user = User.create!(email:, password:, family_name:, given_name:, fallback_role:) diff --git a/script/generate_model_office_data.rb b/script/generate_model_office_data.rb index b632235086..2c340686e1 100644 --- a/script/generate_model_office_data.rb +++ b/script/generate_model_office_data.rb @@ -185,7 +185,7 @@ def write_vaccination_records_to_file(vaccination_records) end csv << [ - vaccination_record.team.ods_code, + vaccination_record.organisation.ods_code, school_urn(vaccination_record.patient), school_name, vaccination_record.patient.nhs_number, diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 1522732243..e3690d5a3f 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -48,7 +48,7 @@ { "selected_org" => { "name" => team.name, - "code" => team.ods_code + "code" => team.organisation.ods_code }, "selected_role" => { "name" => selected_role_name, diff --git a/spec/factories/vaccination_records.rb b/spec/factories/vaccination_records.rb index d5ff7b54b8..a600cb066c 100644 --- a/spec/factories/vaccination_records.rb +++ b/spec/factories/vaccination_records.rb @@ -60,13 +60,14 @@ factory :vaccination_record do transient do team do - programme.teams.first || association(:team, programmes: [programme]) + programme.teams.includes(:organisation).first || + association(:team, programmes: [programme]) end end programme - performed_ods_code { team.ods_code } + performed_ods_code { team.organisation.ods_code } patient do association :patient, diff --git a/spec/features/cli_generate_consents_spec.rb b/spec/features/cli_generate_consents_spec.rb index a01c9f5db2..f30ba04391 100644 --- a/spec/features/cli_generate_consents_spec.rb +++ b/spec/features/cli_generate_consents_spec.rb @@ -36,7 +36,7 @@ def when_i_run_the_generate_consents_command "generate", "consents", "-o", - @team.ods_code.to_s, + @team.organisation.ods_code.to_s, "-p", @programme.type, "-s", diff --git a/spec/features/cli_generate_vaccination_records_spec.rb b/spec/features/cli_generate_vaccination_records_spec.rb index d48de6e131..a41b389030 100644 --- a/spec/features/cli_generate_vaccination_records_spec.rb +++ b/spec/features/cli_generate_vaccination_records_spec.rb @@ -39,7 +39,7 @@ def when_i_run_the_generate_vaccination_records_command "generate", "vaccination-records", "-o", - @team.ods_code.to_s, + @team.organisation.ods_code.to_s, "-p", @programme.type, "-s", diff --git a/spec/features/cli_teams_add_programme_spec.rb b/spec/features/cli_teams_add_programme_spec.rb index efa95e346e..af1594470b 100644 --- a/spec/features/cli_teams_add_programme_spec.rb +++ b/spec/features/cli_teams_add_programme_spec.rb @@ -3,16 +3,26 @@ require_relative "../../app/lib/mavis_cli" describe "mavis teams add-programme" do + context "when the organisation doesn't exist" do + it "displays an error message" do + when_i_run_the_command_expecting_an_error + then_an_organisation_not_found_error_message_is_displayed + end + end + context "when the team doesn't exist" do it "displays an error message" do + given_the_organisation_exists + when_i_run_the_command_expecting_an_error - then_an_team_not_found_error_message_is_displayed + then_a_team_not_found_error_message_is_displayed end end context "when the programme doesn't exist" do it "displays an error message" do - given_the_team_exists + given_the_organisation_exists + and_the_team_exists when_i_run_the_command_expecting_an_error then_a_programme_not_found_error_message_is_displayed @@ -21,7 +31,8 @@ context "when the programme exists" do it "runs successfully" do - given_the_team_exists + given_the_organisation_exists + and_the_team_exists and_the_programme_exists when_i_run_the_command @@ -32,11 +43,15 @@ private def command - Dry::CLI.new(MavisCLI).call(arguments: %w[teams add-programme ABC flu]) + Dry::CLI.new(MavisCLI).call(arguments: %w[teams add-programme ABC Team flu]) + end + + def given_the_organisation_exists + @organisation = create(:organisation, ods_code: "ABC") end - def given_the_team_exists - @team = create(:team, ods_code: "ABC") + def and_the_team_exists + @team = create(:team, organisation: @organisation, name: "Team") @school = create(:school, :secondary, team: @team) end @@ -52,7 +67,11 @@ def when_i_run_the_command_expecting_an_error @output = capture_error { command } end - def then_an_team_not_found_error_message_is_displayed + def then_an_organisation_not_found_error_message_is_displayed + expect(@output).to include("Could not find organisation.") + end + + def then_a_team_not_found_error_message_is_displayed expect(@output).to include("Could not find team.") end diff --git a/spec/features/cli_teams_create_sessions_spec.rb b/spec/features/cli_teams_create_sessions_spec.rb index d32b02fac2..0dc3d880ef 100644 --- a/spec/features/cli_teams_create_sessions_spec.rb +++ b/spec/features/cli_teams_create_sessions_spec.rb @@ -3,16 +3,26 @@ require_relative "../../app/lib/mavis_cli" describe "mavis teams create-sessions" do + context "when the organisation doesn't exist" do + it "displays an error message" do + when_i_run_the_command_expecting_an_error + then_an_organisation_not_found_error_message_is_displayed + end + end + context "when the team doesn't exist" do it "displays an error message" do + given_the_organisation_exists + when_i_run_the_command_expecting_an_error - then_an_team_not_found_error_message_is_displayed + then_a_team_not_found_error_message_is_displayed end end context "when the team exists" do it "runs successfully" do - given_the_team_exists + given_the_organisation_exists + and_the_team_exists and_the_school_exists when_i_run_the_command @@ -23,12 +33,22 @@ private def command - Dry::CLI.new(MavisCLI).call(arguments: %w[teams create-sessions ABC]) + Dry::CLI.new(MavisCLI).call(arguments: %w[teams create-sessions ABC Team]) + end + + def given_the_organisation_exists + @organisation = create(:organisation, ods_code: "ABC") end - def given_the_team_exists + def and_the_team_exists @programmes = [create(:programme, :flu), create(:programme, :hpv)] - @team = create(:team, ods_code: "ABC", programmes: @programmes) + @team = + create( + :team, + organisation: @organisation, + name: "Team", + programmes: @programmes + ) end def and_the_school_exists @@ -43,7 +63,11 @@ def when_i_run_the_command_expecting_an_error @output = capture_error { command } end - def then_an_team_not_found_error_message_is_displayed + def then_an_organisation_not_found_error_message_is_displayed + expect(@output).to include("Could not find organisation.") + end + + def then_a_team_not_found_error_message_is_displayed expect(@output).to include("Could not find team.") end diff --git a/spec/features/user_cis2_authentication_from_start_page_spec.rb b/spec/features/user_cis2_authentication_from_start_page_spec.rb index d40a35f317..a8a70ddf06 100644 --- a/spec/features/user_cis2_authentication_from_start_page_spec.rb +++ b/spec/features/user_cis2_authentication_from_start_page_spec.rb @@ -37,7 +37,7 @@ def given_a_test_team_is_setup_in_mavis_and_cis2 uid: "123", given_name: "Nurse", family_name: "Test", - org_code: @team.ods_code, + org_code: @team.organisation.ods_code, org_name: @team.name ) end diff --git a/spec/features/user_cis2_authentication_with_redirect_spec.rb b/spec/features/user_cis2_authentication_with_redirect_spec.rb index 45e23ad248..79f1252641 100644 --- a/spec/features/user_cis2_authentication_with_redirect_spec.rb +++ b/spec/features/user_cis2_authentication_with_redirect_spec.rb @@ -18,7 +18,7 @@ def given_a_test_team_is_setup_in_mavis_and_cis2 uid: "123", given_name: "Nurse", family_name: "Test", - org_code: @team.ods_code, + org_code: @team.organisation.ods_code, org_name: @team.name ) end diff --git a/spec/features/user_cis2_authentication_with_wrong_role_spec.rb b/spec/features/user_cis2_authentication_with_wrong_role_spec.rb index ff16dfba43..c173d8a9f5 100644 --- a/spec/features/user_cis2_authentication_with_wrong_role_spec.rb +++ b/spec/features/user_cis2_authentication_with_wrong_role_spec.rb @@ -13,7 +13,7 @@ end def given_i_am_setup_in_mavis_and_cis2_but_with_the_wrong_role - @team = create :team, ods_code: "AB12" + @team = create(:team, ods_code: "AB12") mock_cis2_auth(selected_roleid: "wrong-role") end @@ -43,7 +43,11 @@ def then_i_see_the_team_not_found_error def when_i_click_the_change_role_button_and_select_the_right_role # With don't actually get to select the right role directly in our test # setup so we change the cis2 response to simulate it. - mock_cis2_auth(org_code: @team.ods_code, org_name: @team.name, role: :nurse) + mock_cis2_auth( + org_code: @team.organisation.ods_code, + org_name: @team.name, + role: :nurse + ) click_button "Change role" end end diff --git a/spec/features/user_cis2_authentication_with_wrong_workgroup_spec.rb b/spec/features/user_cis2_authentication_with_wrong_workgroup_spec.rb index 990fe6cf87..68731c38cb 100644 --- a/spec/features/user_cis2_authentication_with_wrong_workgroup_spec.rb +++ b/spec/features/user_cis2_authentication_with_wrong_workgroup_spec.rb @@ -43,7 +43,7 @@ def then_i_see_the_wrong_workgroup_error def when_i_click_the_change_role_button_and_select_the_right_role # With don't actually get to select the right role directly in our test # setup so we change the cis2 response to simulate it. - mock_cis2_auth(org_code: @team.ods_code, org_name: @team.name) + mock_cis2_auth(org_code: @team.organisation.ods_code, org_name: @team.name) click_button "Change role" end end diff --git a/spec/fixtures/files/onboarding/valid.yaml b/spec/fixtures/files/onboarding/valid.yaml index d38686c547..22f614def8 100644 --- a/spec/fixtures/files/onboarding/valid.yaml +++ b/spec/fixtures/files/onboarding/valid.yaml @@ -1,9 +1,11 @@ +organisation: + ods_code: EXAMPLE + team: name: NHS Trust email: example@trust.nhs.uk phone: 07700 900815 phone_instructions: option 1, followed by option 3 - ods_code: EXAMPLE careplus_venue_code: EXAMPLE privacy_notice_url: https://example.com/privacy-notice privacy_policy_url: https://example.com/privacy-policy diff --git a/spec/forms/patient_search_form_spec.rb b/spec/forms/patient_search_form_spec.rb index c3673a5dbe..63cfa233ad 100644 --- a/spec/forms/patient_search_form_spec.rb +++ b/spec/forms/patient_search_form_spec.rb @@ -533,7 +533,7 @@ create( :vaccination_record, patient:, - performed_ods_code: team.ods_code, + performed_ods_code: team.organisation.ods_code, programme: create(:programme, :hpv) ) end diff --git a/spec/lib/fhir_mapper/organisation_spec.rb b/spec/lib/fhir_mapper/organisation_spec.rb new file mode 100644 index 0000000000..e65445d056 --- /dev/null +++ b/spec/lib/fhir_mapper/organisation_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +describe FHIRMapper::Organisation do + let(:ods_code) { "A9A5A" } + + describe "#fhir_reference" do + let(:organisation) { Organisation.new(ods_code:) } + let(:fhir_mapper) { described_class.new(organisation) } + + it "returns a FHIR reference with the correct ODS code" do + reference = organisation.fhir_reference + + expect(reference.type).to eq "Organization" + expect( + reference.identifier.system + ).to eq "https://fhir.nhs.uk/Id/ods-organization-code" + expect(reference.identifier.value).to eq ods_code + end + end +end diff --git a/spec/lib/fhir_mapper/team_spec.rb b/spec/lib/fhir_mapper/team_spec.rb deleted file mode 100644 index cc58e46354..0000000000 --- a/spec/lib/fhir_mapper/team_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -describe FHIRMapper::Team do - let(:ods_code) { "A9A5A" } - - describe ".fhir_reference" do - it "returns a FHIR reference with the correct ODS code" do - reference = described_class.fhir_reference(ods_code:) - - expect(reference.type).to eq "Organization" - expect( - reference.identifier.system - ).to eq "https://fhir.nhs.uk/Id/ods-organization-code" - expect(reference.identifier.value).to eq ods_code - end - end - - describe "#fhir_reference" do - let(:team) { Team.new(ods_code:) } - let(:fhir_mapper) { described_class.new(team) } - - it "returns a FHIR reference with the correct ODS code" do - reference = team.fhir_reference - - expect(reference.type).to eq "Organization" - expect( - reference.identifier.system - ).to eq "https://fhir.nhs.uk/Id/ods-organization-code" - expect(reference.identifier.value).to eq ods_code - end - end -end diff --git a/spec/lib/fhir_mapper/vaccination_record_spec.rb b/spec/lib/fhir_mapper/vaccination_record_spec.rb index aee64214ca..ca468fe255 100644 --- a/spec/lib/fhir_mapper/vaccination_record_spec.rb +++ b/spec/lib/fhir_mapper/vaccination_record_spec.rb @@ -4,7 +4,8 @@ # the VaccinationRecord and has a lot of dependencies on it, so not really # worth it. describe FHIRMapper::VaccinationRecord do - let(:team) { create(:team, programmes: [programme]) } + let(:organisation) { create(:organisation) } + let(:team) { create(:team, organisation:, programmes: [programme]) } let(:programme) { create(:programme, :hpv) } let(:patient_session) do create(:patient_session, programmes: [programme], team:) @@ -17,7 +18,7 @@ let(:vaccination_record) do create( :vaccination_record, - performed_ods_code: team.ods_code, + performed_ods_code: organisation.ods_code, patient:, programme:, session:, @@ -98,7 +99,7 @@ its(:system) { should eq "http://snomed.info/sct" } end - describe "performing team" do + describe "performing organisation" do subject do immunisation_fhir .performer @@ -106,11 +107,13 @@ .actor end - let(:team_fhir_reference) do - Team.fhir_reference(ods_code: vaccination_record.performed_ods_code) + let(:organisation_fhir_reference) do + Organisation.fhir_reference( + ods_code: vaccination_record.performed_ods_code + ) end - it { should eq team_fhir_reference } + it { should eq organisation_fhir_reference } end describe "status" do @@ -254,10 +257,10 @@ its(:reference) { should eq "#Practitioner1" } end - describe "team actor" do + describe "organisation actor" do subject { performer.find { |p| p.actor.type == "Organization" }.actor } - it { should eq team.fhir_reference } + it { should eq organisation.fhir_reference } end end diff --git a/spec/lib/reports/offline_session_exporter_spec.rb b/spec/lib/reports/offline_session_exporter_spec.rb index 3e9e7675b9..b0932e4978 100644 --- a/spec/lib/reports/offline_session_exporter_spec.rb +++ b/spec/lib/reports/offline_session_exporter_spec.rb @@ -30,7 +30,15 @@ def validation_formula(worksheet:, column_name:, row: 1) subject(:call) { described_class.call(session) } shared_examples "generates a report" do - let(:team) { create(:team, :with_generic_clinic, programmes: [programme]) } + let(:organisation) { create(:organisation) } + let(:team) do + create( + :team, + :with_generic_clinic, + organisation:, + programmes: [programme] + ) + end let(:user) { create(:user, email: "nurse@example.com", team:) } let(:subteam) { create(:subteam, team:) } let(:session) do @@ -132,7 +140,7 @@ def validation_formula(worksheet:, column_name:, row: 1) "HEALTH_QUESTION_ANSWERS" => "", "NHS_NUMBER" => patient.nhs_number, "NOTES" => "", - "ORGANISATION_CODE" => team.ods_code, + "ORGANISATION_CODE" => organisation.ods_code, "PERFORMING_PROFESSIONAL_EMAIL" => "", "PERSON_ADDRESS_LINE_1" => patient.address_line_1, "PERSON_FORENAME" => patient.given_name, @@ -210,7 +218,7 @@ def validation_formula(worksheet:, column_name:, row: 1) "HEALTH_QUESTION_ANSWERS" => "", "NHS_NUMBER" => patient.nhs_number, "NOTES" => "Some notes.", - "ORGANISATION_CODE" => team.ods_code, + "ORGANISATION_CODE" => organisation.ods_code, "PERFORMING_PROFESSIONAL_EMAIL" => "nurse@example.com", "PERSON_ADDRESS_LINE_1" => patient.address_line_1, "PERSON_FORENAME" => patient.given_name, @@ -300,7 +308,7 @@ def validation_formula(worksheet:, column_name:, row: 1) "HEALTH_QUESTION_ANSWERS" => "", "NHS_NUMBER" => patient.nhs_number, "NOTES" => "Some notes.", - "ORGANISATION_CODE" => team.ods_code, + "ORGANISATION_CODE" => organisation.ods_code, "PERFORMING_PROFESSIONAL_EMAIL" => "nurse@example.com", "PERSON_ADDRESS_LINE_1" => patient.address_line_1, "PERSON_FORENAME" => patient.given_name, @@ -380,7 +388,7 @@ def validation_formula(worksheet:, column_name:, row: 1) "HEALTH_QUESTION_ANSWERS" => "", "NHS_NUMBER" => patient.nhs_number, "NOTES" => "Some notes.", - "ORGANISATION_CODE" => team.ods_code, + "ORGANISATION_CODE" => organisation.ods_code, "PERFORMING_PROFESSIONAL_EMAIL" => "nurse@example.com", "PERSON_ADDRESS_LINE_1" => patient.address_line_1, "PERSON_FORENAME" => patient.given_name, @@ -449,7 +457,7 @@ def validation_formula(worksheet:, column_name:, row: 1) "HEALTH_QUESTION_ANSWERS" => "", "NHS_NUMBER" => patient.nhs_number, "NOTES" => "", - "ORGANISATION_CODE" => team.ods_code, + "ORGANISATION_CODE" => organisation.ods_code, "PERFORMING_PROFESSIONAL_EMAIL" => "", "PERSON_ADDRESS_LINE_1" => patient.address_line_1, "PERSON_FORENAME" => patient.given_name, @@ -514,7 +522,7 @@ def validation_formula(worksheet:, column_name:, row: 1) "HEALTH_QUESTION_ANSWERS" => "", "NHS_NUMBER" => patient.nhs_number, "NOTES" => "Some notes.", - "ORGANISATION_CODE" => team.ods_code, + "ORGANISATION_CODE" => organisation.ods_code, "PERFORMING_PROFESSIONAL_EMAIL" => "nurse@example.com", "PERSON_ADDRESS_LINE_1" => patient.address_line_1, "PERSON_FORENAME" => patient.given_name, @@ -734,7 +742,7 @@ def validation_formula(worksheet:, column_name:, row: 1) "HEALTH_QUESTION_ANSWERS" => "", "NHS_NUMBER" => patient.nhs_number, "NOTES" => "", - "ORGANISATION_CODE" => team.ods_code, + "ORGANISATION_CODE" => organisation.ods_code, "PERFORMING_PROFESSIONAL_EMAIL" => "", "PERSON_ADDRESS_LINE_1" => patient.address_line_1, "PERSON_FORENAME" => patient.given_name, @@ -818,7 +826,7 @@ def validation_formula(worksheet:, column_name:, row: 1) "HEALTH_QUESTION_ANSWERS" => "", "NHS_NUMBER" => patient.nhs_number, "NOTES" => "Some notes.", - "ORGANISATION_CODE" => team.ods_code, + "ORGANISATION_CODE" => organisation.ods_code, "PERFORMING_PROFESSIONAL_EMAIL" => "nurse@example.com", "PERSON_ADDRESS_LINE_1" => patient.address_line_1, "PERSON_FORENAME" => patient.given_name, diff --git a/spec/lib/reports/programme_vaccinations_exporter_spec.rb b/spec/lib/reports/programme_vaccinations_exporter_spec.rb index 35511ae158..1fbb160d94 100644 --- a/spec/lib/reports/programme_vaccinations_exporter_spec.rb +++ b/spec/lib/reports/programme_vaccinations_exporter_spec.rb @@ -18,7 +18,8 @@ shared_examples "generates a report" do let(:programmes) { [programme] } - let(:team) { create(:team, programmes:) } + let(:organisation) { create(:organisation) } + let(:team) { create(:team, organisation:, programmes:) } let(:user) do create( :user, @@ -149,7 +150,7 @@ "LOCAL_PATIENT_ID" => patient.id.to_s, "NHS_NUMBER" => patient.nhs_number, "NHS_NUMBER_STATUS_CODE" => "02", - "ORGANISATION_CODE" => team.ods_code, + "ORGANISATION_CODE" => organisation.ods_code, "PERFORMING_PROFESSIONAL_EMAIL" => "nurse@example.com", "PERFORMING_PROFESSIONAL_FORENAME" => "Nurse", "PERFORMING_PROFESSIONAL_SURNAME" => "Test", @@ -300,7 +301,7 @@ "LOCAL_PATIENT_ID" => patient.id.to_s, "NHS_NUMBER" => patient.nhs_number, "NHS_NUMBER_STATUS_CODE" => "02", - "ORGANISATION_CODE" => team.ods_code, + "ORGANISATION_CODE" => organisation.ods_code, "PERFORMING_PROFESSIONAL_EMAIL" => "nurse@example.com", "PERFORMING_PROFESSIONAL_FORENAME" => "Nurse", "PERFORMING_PROFESSIONAL_SURNAME" => "Test", diff --git a/spec/models/immunisation_import_row_spec.rb b/spec/models/immunisation_import_row_spec.rb index 38b84a990b..9d3abad61b 100644 --- a/spec/models/immunisation_import_row_spec.rb +++ b/spec/models/immunisation_import_row_spec.rb @@ -361,7 +361,7 @@ "CARE_SETTING" => "2", "DATE_OF_VACCINATION" => session.dates.first.strftime("%Y%m%d"), "SESSION_ID" => session.id.to_s, - "ORGANISATION_CODE" => team.ods_code, + "ORGANISATION_CODE" => team.organisation.ods_code, "PERFORMING_PROFESSIONAL_EMAIL" => create(:user).email ) end @@ -1211,7 +1211,7 @@ valid_data.merge( "DATE_OF_VACCINATION" => session.dates.first.strftime("%Y%m%d"), "SESSION_ID" => session.id.to_s, - "ORGANISATION_CODE" => team.ods_code, + "ORGANISATION_CODE" => team.organisation.ods_code, "PERFORMING_PROFESSIONAL_EMAIL" => create(:user).email, "DOSE_SEQUENCE" => "1" ) diff --git a/spec/models/location_spec.rb b/spec/models/location_spec.rb index 3a6ee72b2b..d0c8c10776 100644 --- a/spec/models/location_spec.rb +++ b/spec/models/location_spec.rb @@ -60,7 +60,7 @@ it do expect(location).to validate_exclusion_of(:ods_code).in_array( - [team.ods_code] + [team.organisation.ods_code] ) end diff --git a/spec/models/onboarding_spec.rb b/spec/models/onboarding_spec.rb index 31af5fb7bb..17d8ce98da 100644 --- a/spec/models/onboarding_spec.rb +++ b/spec/models/onboarding_spec.rb @@ -23,8 +23,10 @@ it "set up the models" do expect { onboarding.save! }.not_to raise_error - team = Team.find_by!(ods_code: "EXAMPLE") - expect(team.name).to eq("NHS Trust") + organisation = Organisation.find_by(ods_code: "EXAMPLE") + expect(organisation).not_to be_nil + + team = Team.find_by(organisation:, name: "NHS Trust") expect(team.email).to eq("example@trust.nhs.uk") expect(team.phone).to eq("07700 900815") expect(team.phone_instructions).to eq("option 1, followed by option 3") @@ -77,9 +79,9 @@ expect(onboarding.errors.messages).to eq( { + "organisation.ods_code": ["can't be blank"], "team.careplus_venue_code": ["can't be blank"], "team.name": ["can't be blank"], - "team.ods_code": ["can't be blank"], "team.phone": ["can't be blank", "is invalid"], "team.privacy_notice_url": ["can't be blank"], "team.privacy_policy_url": ["can't be blank"], diff --git a/spec/policies/patient_policy_spec.rb b/spec/policies/patient_policy_spec.rb index 4c742c6181..e2fcc21999 100644 --- a/spec/policies/patient_policy_spec.rb +++ b/spec/policies/patient_policy_spec.rb @@ -83,7 +83,7 @@ create( :vaccination_record, patient: patient_with_vaccination_record, - performed_ods_code: team.ods_code, + performed_ods_code: team.organisation.ods_code, programme: programmes.first ) create( diff --git a/spec/requests/api/testing/onboard_spec.rb b/spec/requests/api/testing/onboard_spec.rb index f6244ab7bb..ba58d6aba3 100644 --- a/spec/requests/api/testing/onboard_spec.rb +++ b/spec/requests/api/testing/onboard_spec.rb @@ -58,9 +58,9 @@ expect(errors).to eq( { "clinics" => ["can't be blank"], + "organisation.ods_code" => ["can't be blank"], "team.careplus_venue_code" => ["can't be blank"], "team.name" => ["can't be blank"], - "team.ods_code" => ["can't be blank"], "team.phone" => ["can't be blank", "is invalid"], "team.privacy_notice_url" => ["can't be blank"], "team.privacy_policy_url" => ["can't be blank"], diff --git a/spec/support/cis2_auth_helper.rb b/spec/support/cis2_auth_helper.rb index 02adc9e1de..72a9079d2f 100644 --- a/spec/support/cis2_auth_helper.rb +++ b/spec/support/cis2_auth_helper.rb @@ -119,7 +119,7 @@ def cis2_sign_in(user, role: :nurse, org_code: nil, superuser: false) # Define a sign_in that is compatible with Devise's sign_in. def sign_in(user, role: :nurse, org_code: nil, superuser: false) - org_code ||= user.teams.first.ods_code + org_code ||= user.teams.first.organisation.ods_code cis2_sign_in(user, role:, org_code:, superuser:) end @@ -148,7 +148,7 @@ def mock_cis2_auth( if user_only_has_one_role raw_info["nhsid_nrbac_roles"].select! do - _1["person_roleid"] == selected_roleid + it["person_roleid"] == selected_roleid end end From 0558a84be8521bc7ced69c29679596f405b0db03 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 30 Jul 2025 06:45:37 +0100 Subject: [PATCH 10/58] Create ArchiveReason model This adds a new model which will be used to represent a patient being archived meaning that it should no longer appear in any sessions, but should still be accessible from the global patients search. Jira-Issue: MAV-1506 --- app/models/archive_reason.rb | 50 ++++++++++++++++ app/models/consent_notification.rb | 2 +- app/models/location.rb | 2 +- app/models/patient.rb | 1 + app/models/session_notification.rb | 2 +- app/models/team.rb | 1 + .../20250730052926_create_archive_reason.rb | 15 +++++ db/schema.rb | 17 ++++++ spec/factories/archive_reasons.rb | 41 +++++++++++++ spec/models/archive_reason_spec.rb | 58 +++++++++++++++++++ spec/models/patient_spec.rb | 4 ++ spec/models/team_spec.rb | 1 + 12 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 app/models/archive_reason.rb create mode 100644 db/migrate/20250730052926_create_archive_reason.rb create mode 100644 spec/factories/archive_reasons.rb create mode 100644 spec/models/archive_reason_spec.rb diff --git a/app/models/archive_reason.rb b/app/models/archive_reason.rb new file mode 100644 index 0000000000..8ae1766848 --- /dev/null +++ b/app/models/archive_reason.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: archive_reasons +# +# id :bigint not null, primary key +# other_details :string default(""), not null +# type :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# created_by_user_id :bigint +# patient_id :bigint not null +# team_id :bigint not null +# +# Indexes +# +# index_archive_reasons_on_created_by_user_id (created_by_user_id) +# index_archive_reasons_on_patient_id (patient_id) +# index_archive_reasons_on_team_id (team_id) +# index_archive_reasons_on_team_id_and_patient_id (team_id,patient_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (created_by_user_id => users.id) +# fk_rails_... (patient_id => patients.id) +# fk_rails_... (team_id => teams.id) +# +class ArchiveReason < ApplicationRecord + self.inheritance_column = nil + + belongs_to :team + belongs_to :patient + belongs_to :created_by, + class_name: "User", + foreign_key: :created_by_user_id, + optional: true + + enum :type, + { imported_in_error: 0, moved_out_of_area: 1, deceased: 2, other: 3 }, + validate: true + + validates :other_details, + presence: true, + length: { + maximum: 300 + }, + if: :other? + validates :other_details, absence: true, unless: :other? +end diff --git a/app/models/consent_notification.rb b/app/models/consent_notification.rb index 65143ed422..6bace6ab83 100644 --- a/app/models/consent_notification.rb +++ b/app/models/consent_notification.rb @@ -26,7 +26,7 @@ class ConsentNotification < ApplicationRecord include Sendable - self.inheritance_column = :nil + self.inheritance_column = nil belongs_to :patient belongs_to :session diff --git a/app/models/location.rb b/app/models/location.rb index 3439fdd594..e482f6e198 100644 --- a/app/models/location.rb +++ b/app/models/location.rb @@ -36,7 +36,7 @@ class Location < ApplicationRecord include AddressConcern include ODSCodeConcern - self.inheritance_column = :nil + self.inheritance_column = nil audited associated_with: :subteam has_associated_audits diff --git a/app/models/patient.rb b/app/models/patient.rb index 1662a2f9fa..53ba849358 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -60,6 +60,7 @@ class Patient < ApplicationRecord belongs_to :gp_practice, class_name: "Location", optional: true has_many :access_log_entries + has_many :archive_reasons has_many :consent_notifications has_many :consent_statuses has_many :consents diff --git a/app/models/session_notification.rb b/app/models/session_notification.rb index f9a61bc544..8235055fc6 100644 --- a/app/models/session_notification.rb +++ b/app/models/session_notification.rb @@ -27,7 +27,7 @@ class SessionNotification < ApplicationRecord include Sendable - self.inheritance_column = :nil + self.inheritance_column = nil belongs_to :patient belongs_to :session diff --git a/app/models/team.rb b/app/models/team.rb index 67a1c8d20f..c93366c74a 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -35,6 +35,7 @@ class Team < ApplicationRecord belongs_to :organisation + has_many :archive_reasons has_many :batches has_many :cohort_imports has_many :consent_forms diff --git a/db/migrate/20250730052926_create_archive_reason.rb b/db/migrate/20250730052926_create_archive_reason.rb new file mode 100644 index 0000000000..e2970f11d1 --- /dev/null +++ b/db/migrate/20250730052926_create_archive_reason.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateArchiveReason < ActiveRecord::Migration[8.0] + def change + create_table :archive_reasons do |t| + t.references :team, foreign_key: true, null: false + t.references :patient, foreign_key: true, null: false + t.references :created_by_user, foreign_key: { to_table: :users } + t.integer :type, null: false + t.string :other_details, null: false, default: "" + t.timestamps + t.index %w[team_id patient_id], unique: true + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 11d0fc5afb..996672496d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -35,6 +35,20 @@ t.index ["updated_at"], name: "index_active_record_sessions_on_updated_at" end + create_table "archive_reasons", force: :cascade do |t| + t.bigint "team_id", null: false + t.bigint "patient_id", null: false + t.bigint "created_by_user_id" + t.integer "type", null: false + t.string "other_details", default: "", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_by_user_id"], name: "index_archive_reasons_on_created_by_user_id" + t.index ["patient_id"], name: "index_archive_reasons_on_patient_id" + t.index ["team_id", "patient_id"], name: "index_archive_reasons_on_team_id_and_patient_id", unique: true + t.index ["team_id"], name: "index_archive_reasons_on_team_id" + end + create_table "audits", force: :cascade do |t| t.integer "auditable_id" t.string "auditable_type" @@ -890,6 +904,9 @@ 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" + add_foreign_key "archive_reasons", "users", column: "created_by_user_id" add_foreign_key "batches", "teams" add_foreign_key "batches", "vaccines" add_foreign_key "batches_immunisation_imports", "batches" diff --git a/spec/factories/archive_reasons.rb b/spec/factories/archive_reasons.rb new file mode 100644 index 0000000000..44d7a5a79d --- /dev/null +++ b/spec/factories/archive_reasons.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: archive_reasons +# +# id :bigint not null, primary key +# other_details :string default(""), not null +# type :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# created_by_user_id :bigint +# patient_id :bigint not null +# team_id :bigint not null +# +# Indexes +# +# index_archive_reasons_on_created_by_user_id (created_by_user_id) +# index_archive_reasons_on_patient_id (patient_id) +# index_archive_reasons_on_team_id (team_id) +# index_archive_reasons_on_team_id_and_patient_id (team_id,patient_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (created_by_user_id => users.id) +# fk_rails_... (patient_id => patients.id) +# fk_rails_... (team_id => teams.id) +# +FactoryBot.define do + factory :archive_reason do + team + patient + + traits_for_enum :type + + trait :other do + type { "other" } + other_details { Faker::Lorem.sentence } + end + end +end diff --git a/spec/models/archive_reason_spec.rb b/spec/models/archive_reason_spec.rb new file mode 100644 index 0000000000..246edd6e05 --- /dev/null +++ b/spec/models/archive_reason_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: archive_reasons +# +# id :bigint not null, primary key +# other_details :string default(""), not null +# type :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# created_by_user_id :bigint +# patient_id :bigint not null +# team_id :bigint not null +# +# Indexes +# +# index_archive_reasons_on_created_by_user_id (created_by_user_id) +# index_archive_reasons_on_patient_id (patient_id) +# index_archive_reasons_on_team_id (team_id) +# index_archive_reasons_on_team_id_and_patient_id (team_id,patient_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (created_by_user_id => users.id) +# fk_rails_... (patient_id => patients.id) +# fk_rails_... (team_id => teams.id) +# +describe ArchiveReason do + subject(:archive_reason) { build(:archive_reason) } + + describe "associations" do + it { should belong_to(:created_by).class_name("User").optional(true) } + it { should belong_to(:patient) } + it { should belong_to(:team) } + end + + describe "validations" do + it do + expect(archive_reason).to validate_inclusion_of(:type).in_array( + %w[imported_in_error moved_out_of_area deceased other] + ) + end + + context "when type is not other" do + before { archive_reason.type = "imported_in_error" } + + it { should validate_absence_of(:other_details) } + end + + context "when type is other" do + before { archive_reason.type = "other" } + + it { should validate_presence_of(:other_details) } + it { should validate_length_of(:other_details).is_at_most(300) } + end + end +end diff --git a/spec/models/patient_spec.rb b/spec/models/patient_spec.rb index c5341cd136..8d4ab670dd 100644 --- a/spec/models/patient_spec.rb +++ b/spec/models/patient_spec.rb @@ -48,6 +48,10 @@ # describe Patient do + describe "associations" do + it { should have_many(:archive_reasons) } + end + describe "scopes" do describe "#appear_in_programmes" do subject(:scope) do diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb index c0ff707885..cef02124b4 100644 --- a/spec/models/team_spec.rb +++ b/spec/models/team_spec.rb @@ -35,6 +35,7 @@ describe "associations" do it { should belong_to(:organisation) } + it { should have_many(:archive_reasons) } end describe "validations" do From c37b603685458a18a71f58dab1818725cee107fe Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 30 Jul 2025 07:33:44 +0100 Subject: [PATCH 11/58] Add archived and not_archived scopes This adds two scopes to the patient model that can be used to find patients in the two possible archived states for a particular organisation. Jira-Issue: MAV-1506 --- app/models/patient.rb | 23 +++++++++++++++++++ spec/models/patient_spec.rb | 46 +++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/app/models/patient.rb b/app/models/patient.rb index 53ba849358..467fdd80bc 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -96,6 +96,29 @@ class Patient < ApplicationRecord # https://www.datadictionary.nhs.uk/attributes/person_gender_code.html enum :gender_code, { not_known: 0, male: 1, female: 2, not_specified: 9 } + scope :joins_archive_reasons, + ->(team:) do + joins( + "LEFT JOIN archive_reasons " \ + "ON archive_reasons.patient_id = patients.id " \ + "AND archive_reasons.team_id = #{team.id}" + ) + end + + scope :archived, + ->(team:) do + joins_archive_reasons(team:).where( + "archive_reasons.id IS NOT NULL" + ) + end + + scope :not_archived, + ->(team:) do + joins_archive_reasons(team:).where( + "archive_reasons.id IS NULL" + ) + end + scope :with_nhs_number, -> { where.not(nhs_number: nil) } scope :without_nhs_number, -> { where(nhs_number: nil) } diff --git a/spec/models/patient_spec.rb b/spec/models/patient_spec.rb index 8d4ab670dd..de70b2e077 100644 --- a/spec/models/patient_spec.rb +++ b/spec/models/patient_spec.rb @@ -53,6 +53,52 @@ end describe "scopes" do + describe "#archived" do + subject(:scope) { described_class.archived(team:) } + + let(:patient) { create(:patient) } + let(:team) { create(:team) } + + context "without an archive reason" do + it { should_not include(patient) } + end + + context "with an archive reason for the team" do + before { create(:archive_reason, :moved_out_of_area, team:, patient:) } + + it { should include(patient) } + end + + context "with an archive reason for a different team" do + before { create(:archive_reason, :imported_in_error, patient:) } + + it { should_not include(patient) } + end + end + + describe "#not_archived" do + subject(:scope) { described_class.not_archived(team:) } + + let(:patient) { create(:patient) } + let(:team) { create(:team) } + + context "without an archive reason" do + it { should include(patient) } + end + + context "with an archive reason for the team" do + before { create(:archive_reason, :moved_out_of_area, team:, patient:) } + + it { should_not include(patient) } + end + + context "with an archive reason for a different team" do + before { create(:archive_reason, :imported_in_error, patient:) } + + it { should include(patient) } + end + end + describe "#appear_in_programmes" do subject(:scope) do described_class.appear_in_programmes(programmes, academic_year:) From fefff0ca00f92ed97657525497fa1533480bb78f Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 30 Jul 2025 06:59:39 +0100 Subject: [PATCH 12/58] Include archived patients in policy When viewing the patients for a particular organisation this updates the policy to ensure that patients who have been archived are included in the scope so they're shown in various parts of the service. Any where that shouldn't show archived patients will then exclude those. Jira-Issue: MAV-1506 --- app/models/patient.rb | 8 ++------ app/policies/patient_policy.rb | 3 ++- spec/policies/patient_policy_spec.rb | 24 +++++++++++++++++++++++- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/app/models/patient.rb b/app/models/patient.rb index 467fdd80bc..4ccf9a9c3b 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -107,16 +107,12 @@ class Patient < ApplicationRecord scope :archived, ->(team:) do - joins_archive_reasons(team:).where( - "archive_reasons.id IS NOT NULL" - ) + joins_archive_reasons(team:).where("archive_reasons.id IS NOT NULL") end scope :not_archived, ->(team:) do - joins_archive_reasons(team:).where( - "archive_reasons.id IS NULL" - ) + joins_archive_reasons(team:).where("archive_reasons.id IS NULL") end scope :with_nhs_number, -> { where.not(nhs_number: nil) } diff --git a/app/policies/patient_policy.rb b/app/policies/patient_policy.rb index 21fa356670..b8b4cb7b75 100644 --- a/app/policies/patient_policy.rb +++ b/app/policies/patient_policy.rb @@ -40,7 +40,8 @@ def resolve .exists scope - .where(patient_session_exists) + .archived(team:) + .or(scope.where(patient_session_exists)) .or(scope.where(school_move_exists)) .or(scope.where(vaccination_record_exists)) end diff --git a/spec/policies/patient_policy_spec.rb b/spec/policies/patient_policy_spec.rb index e2fcc21999..562f462982 100644 --- a/spec/policies/patient_policy_spec.rb +++ b/spec/policies/patient_policy_spec.rb @@ -9,7 +9,29 @@ let(:another_team) { create(:team, programmes:) } let(:user) { create(:user, team:) } - context "when patient is in a session" do + context "when a patient is archived" do + let(:patient_archived_in_team) { create(:patient) } + let(:patient_not_archived_in_team) { create(:patient) } + + before do + create( + :archive_reason, + :imported_in_error, + patient: patient_archived_in_team, + team: + ) + create( + :archive_reason, + :other, + patient: patient_not_archived_in_team, + team: another_team + ) + end + + it { should contain_exactly(patient_archived_in_team) } + end + + context "when a patient is in a session" do let(:patient_in_session) { create(:patient) } let(:patient_not_in_session) { create(:patient) } From fce743de00a26e6be4c5ee1f1168aa045d56536b Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 30 Jul 2025 09:51:03 +0100 Subject: [PATCH 13/58] Add archive entries to activity log When a patient is archived, this adds an entry to the activity log matching the latest designs in the prototype. Jira-Issue: MAV-1506 --- app/components/app_activity_log_component.rb | 21 +++++++++++++++--- .../patient_sessions/activities/show.html.erb | 5 ++++- app/views/patients/log.html.erb | 5 ++++- .../app_activity_log_component_spec.rb | 22 ++++++++++++++++++- 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/app/components/app_activity_log_component.rb b/app/components/app_activity_log_component.rb index 6d4744112e..dec73408ea 100644 --- a/app/components/app_activity_log_component.rb +++ b/app/components/app_activity_log_component.rb @@ -14,7 +14,7 @@ class AppActivityLogComponent < ViewComponent::Base <% end %> ERB - def initialize(patient: nil, patient_session: nil) + def initialize(team:, patient: nil, patient_session: nil) super if patient.nil? && patient_session.nil? @@ -63,14 +63,17 @@ def initialize(patient: nil, patient_session: nil) ) @patient_specific_directions = @patient.patient_specific_directions + + @archive_reasons = @patient.archive_reasons.where(team:) end - attr_reader :patient, - :patient_sessions, + attr_reader :archive_reasons, :consents, :gillick_assessments, :notes, :notify_log_entries, + :patient, + :patient_sessions, :pre_screenings, :session_attendances, :triages, @@ -83,6 +86,7 @@ def events_by_day def all_events [ + archive_events, attendance_events, consent_events, gillick_assessment_events, @@ -96,6 +100,17 @@ def all_events ].flatten end + def archive_events + archive_reasons.flat_map do |archive_reason| + { + title: "Record archived: #{archive_reason.human_enum_name(:type)}", + body: archive_reason.other_details, + at: archive_reason.created_at, + by: archive_reason.created_by + } + end + end + def consent_events consents.flat_map do |consent| events = [] diff --git a/app/views/patient_sessions/activities/show.html.erb b/app/views/patient_sessions/activities/show.html.erb index 660c1930f2..272571eff9 100644 --- a/app/views/patient_sessions/activities/show.html.erb +++ b/app/views/patient_sessions/activities/show.html.erb @@ -2,4 +2,7 @@ <%= render AppCreateNoteComponent.new(@note, open: action_name == "create") %> -<%= render AppActivityLogComponent.new(patient_session: @patient_session) %> +<%= render AppActivityLogComponent.new( + patient_session: @patient_session, + team: current_user.selected_team, + ) %> diff --git a/app/views/patients/log.html.erb b/app/views/patients/log.html.erb index 1a78086212..39f7627c9c 100644 --- a/app/views/patients/log.html.erb +++ b/app/views/patients/log.html.erb @@ -14,4 +14,7 @@ nav.with_item(href: log_patient_path(@patient), text: "Activity log", selected: true) end %> -<%= render AppActivityLogComponent.new(patient: @patient) %> +<%= render AppActivityLogComponent.new( + patient: @patient, + team: current_user.selected_team, + ) %> diff --git a/spec/components/app_activity_log_component_spec.rb b/spec/components/app_activity_log_component_spec.rb index 8e5b5a7bb1..6b58abec71 100644 --- a/spec/components/app_activity_log_component_spec.rb +++ b/spec/components/app_activity_log_component_spec.rb @@ -3,7 +3,7 @@ describe AppActivityLogComponent do subject(:rendered) { render_inline(component) } - let(:component) { described_class.new(patient_session:) } + let(:component) { described_class.new(patient_session:, team:) } let(:today) { Date.new(2026, 1, 1) } @@ -66,6 +66,26 @@ end end + describe "archive reasons" do + before do + create( + :archive_reason, + :other, + created_at: Time.zone.local(2024, 6, 1, 12), + created_by: user, + team:, + other_details: "Extra details", + patient: + ) + end + + include_examples "card", + title: "Record archived: Other", + notes: "Extra details", + date: "1 June 2024 at 12:00pm", + by: "JOY, Nurse" + end + describe "consent given by parents" do before do create( From 8561dcb913b0aeac754cdc9b9de3b454ff77e53c Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 30 Jul 2025 10:01:06 +0100 Subject: [PATCH 14/58] Add archived? and not_archived? methods This adds two methods to the patient model allowing the archive state of the patient to be determined. Jira-Issue: MAV-1506 --- app/models/patient.rb | 16 +++++++++---- spec/models/patient_spec.rb | 46 +++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/app/models/patient.rb b/app/models/patient.rb index 4ccf9a9c3b..f6cee1b8bc 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -303,10 +303,10 @@ def self.match_existing( # to avoid an extra query to the database for each record. exact_results = results.select do - _1.given_name.downcase == given_name.downcase && - _1.family_name.downcase == family_name.downcase && - _1.date_of_birth == date_of_birth && - _1.address_postcode == UKPostcode.parse(address_postcode).to_s + it.given_name.downcase == given_name.downcase && + it.family_name.downcase == family_name.downcase && + it.date_of_birth == date_of_birth && + it.address_postcode == UKPostcode.parse(address_postcode).to_s end return exact_results if exact_results.length == 1 @@ -315,6 +315,14 @@ def self.match_existing( results end + def archived?(team:) + archive_reasons.exists?(team:) + end + + def not_archived?(team:) + !archive_reasons.exists?(team:) + end + def year_group(academic_year: nil) academic_year ||= AcademicYear.current birth_academic_year.to_year_group(academic_year:) diff --git a/spec/models/patient_spec.rb b/spec/models/patient_spec.rb index de70b2e077..3d20e26fec 100644 --- a/spec/models/patient_spec.rb +++ b/spec/models/patient_spec.rb @@ -466,6 +466,52 @@ end end + describe "#archived?" do + subject(:archived?) { patient.archived?(team:) } + + let(:patient) { create(:patient) } + let(:team) { create(:team) } + + context "without an archive reason" do + it { should be(false) } + end + + context "with an archive reason for the team" do + before { create(:archive_reason, :moved_out_of_area, team:, patient:) } + + it { should be(true) } + end + + context "with an archive reason for a different team" do + before { create(:archive_reason, :imported_in_error, patient:) } + + it { should be(false) } + end + end + + describe "#not_archived?" do + subject(:not_archived?) { patient.not_archived?(team:) } + + let(:patient) { create(:patient) } + let(:team) { create(:team) } + + context "without an archive reason" do + it { should be(true) } + end + + context "with an archive reason for the team" do + before { create(:archive_reason, :moved_out_of_area, team:, patient:) } + + it { should be(false) } + end + + context "with an archive reason for a different team" do + before { create(:archive_reason, :imported_in_error, patient:) } + + it { should be(true) } + end + end + describe "#initials" do subject(:initials) { patient.initials } From e31e3eb5d8c01b72390cc69eb05bf1caa22556d0 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 30 Jul 2025 13:16:09 +0100 Subject: [PATCH 15/58] Archive patients on receipt of date of death When marking a patient as deceased we should also attach an archive reason to the patient so they remain part of the organisation(s) but no longer appear in any child views. Jira-Issue: MAV-1532 --- app/controllers/patients_controller.rb | 5 ++++- app/controllers/programmes/base_controller.rb | 7 ++++--- app/models/patient.rb | 19 +++++++++++++++---- app/views/programmes/index.html.erb | 2 +- spec/models/patient_spec.rb | 11 +++++++++++ 5 files changed, 35 insertions(+), 9 deletions(-) diff --git a/app/controllers/patients_controller.rb b/app/controllers/patients_controller.rb index b9736da355..863f61945d 100644 --- a/app/controllers/patients_controller.rb +++ b/app/controllers/patients_controller.rb @@ -8,7 +8,10 @@ class PatientsController < ApplicationController before_action :record_access_log_entry, only: %i[show log] def index - patients = @form.apply(policy_scope(Patient).includes(:school).not_deceased) + patients = + @form.apply( + policy_scope(Patient).includes(:school).not_archived(team: current_team) + ) @pagy, @patients = pagy(patients) diff --git a/app/controllers/programmes/base_controller.rb b/app/controllers/programmes/base_controller.rb index a76a2f952a..c36e8277ea 100644 --- a/app/controllers/programmes/base_controller.rb +++ b/app/controllers/programmes/base_controller.rb @@ -24,8 +24,9 @@ def patients # We do this instead of using `team.patients` as that has a `distinct` on # it which means we cannot apply ordering or grouping. @patients ||= - Patient.where( - id: current_team.patient_sessions.select(:patient_id).distinct - ).appear_in_programmes([@programme], academic_year: @academic_year) + Patient + .where(id: current_team.patient_sessions.select(:patient_id).distinct) + .appear_in_programmes([@programme], academic_year: @academic_year) + .not_archived(team: current_team) end end diff --git a/app/models/patient.rb b/app/models/patient.rb index f6cee1b8bc..a223074ca0 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -118,10 +118,8 @@ class Patient < ApplicationRecord scope :with_nhs_number, -> { where.not(nhs_number: nil) } scope :without_nhs_number, -> { where(nhs_number: nil) } - scope :not_deceased, -> { where(date_of_death: nil) } scope :deceased, -> { where.not(date_of_death: nil) } - - scope :not_restricted, -> { where(restricted_at: nil) } + scope :not_deceased, -> { where(date_of_death: nil) } scope :restricted, -> { where.not(restricted_at: nil) } scope :with_notice, -> { deceased.or(restricted).or(invalidated) } @@ -398,7 +396,11 @@ def update_from_pds!(pds_patient) self.date_of_death = pds_patient.date_of_death if date_of_death_changed? - clear_pending_sessions! unless date_of_death.nil? + if date_of_death.present? + archive_due_to_deceased! + clear_pending_sessions! + end + self.date_of_death_recorded_at = Time.current end @@ -510,6 +512,15 @@ def clear_pending_sessions! patient_sessions.where(session: pending_sessions).destroy_all_if_safe end + def archive_due_to_deceased! + archive_reasons = + teams.map do |team| + ArchiveReason.new(team:, patient: self, type: :deceased) + end + + ArchiveReason.import!(archive_reasons, on_duplicate_key_update: :all) + end + def fhir_mapper = @fhir_mapper ||= FHIRMapper::Patient.new(self) def sync_vaccinations_to_nhs_immunisations_api diff --git a/app/views/programmes/index.html.erb b/app/views/programmes/index.html.erb index 1a63141fe3..699ea36383 100644 --- a/app/views/programmes/index.html.erb +++ b/app/views/programmes/index.html.erb @@ -15,7 +15,7 @@ <% table.with_body do |body| %> <% @programmes.each do |programme| %> - <% patients = current_team.patients.appear_in_programmes([programme], academic_year:) %> + <% patients = current_team.patients.appear_in_programmes([programme], academic_year:).not_archived(team: current_team) %> <% body.with_row do |row| %> <% row.with_cell do %> diff --git a/spec/models/patient_spec.rb b/spec/models/patient_spec.rb index 3d20e26fec..92110e070d 100644 --- a/spec/models/patient_spec.rb +++ b/spec/models/patient_spec.rb @@ -646,6 +646,17 @@ update_from_pds! expect(session.patients).not_to include(patient) end + + it "archives the patient" do + expect { update_from_pds! }.to change( + patient.archive_reasons, + :count + ).from(0).to(1) + + archive_reason = patient.archive_reasons.first + expect(archive_reason).to be_deceased + expect(archive_reason.team_id).to eq(session.team_id) + end end end From 28df465c7bb14c70f5f90c023a11335cd9006947 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 30 Jul 2025 15:49:41 +0100 Subject: [PATCH 16/58] Handle merging archived patients This updates the `PatientMerger` service class to handle merging patients where either one of the patients, or both of the patients, are archived. The logic works as follows: - No patients archived, resulting patient is not archived - One of the patients is archived, resulting patient is not archived - Both of the patients are archived, resulting patient is archived Jira-Issue: MAV-1506 --- app/lib/patient_merger.rb | 11 +++++++ spec/lib/patient_merger_spec.rb | 57 ++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/app/lib/patient_merger.rb b/app/lib/patient_merger.rb index fa769aa39e..ac411ce34a 100644 --- a/app/lib/patient_merger.rb +++ b/app/lib/patient_merger.rb @@ -13,6 +13,17 @@ def call patient_to_destroy.access_log_entries.update_all( patient_id: patient_to_keep.id ) + + patient_to_keep.archive_reasons.find_each do |archive_reason| + unless patient_to_destroy.archive_reasons.exists?( + team_id: archive_reason.team_id + ) + archive_reason.destroy! + end + end + + patient_to_destroy.archive_reasons.destroy_all + patient_to_destroy.consent_notifications.update_all( patient_id: patient_to_keep.id ) diff --git a/spec/lib/patient_merger_spec.rb b/spec/lib/patient_merger_spec.rb index e18ce9feac..8f0ce907fe 100644 --- a/spec/lib/patient_merger_spec.rb +++ b/spec/lib/patient_merger_spec.rb @@ -24,7 +24,8 @@ let(:user) { create(:user) } let(:programme) { create(:programme, :hpv) } - let(:session) { create(:session, programmes: [programme]) } + let(:team) { create(:team, programmes: [programme]) } + let(:session) { create(:session, team:, programmes: [programme]) } let!(:patient_to_keep) { create(:patient, year_group: 8) } let!(:patient_to_destroy) { create(:patient, year_group: 8) } @@ -189,5 +190,59 @@ ) end end + + context "when patient to keep is archived" do + before do + create( + :archive_reason, + :moved_out_of_area, + patient: patient_to_keep, + team: + ) + end + + 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) + end + end + + context "when patient to destroy is archived" do + before do + create( + :archive_reason, + :moved_out_of_area, + patient: patient_to_destroy, + team: + ) + end + + 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) + end + end + + context "when both patients are archived" do + before do + create( + :archive_reason, + :moved_out_of_area, + patient: patient_to_keep, + team: + ) + create( + :archive_reason, + :moved_out_of_area, + patient: patient_to_destroy, + team: + ) + end + + 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) + end + end end end From 8922a5450334dec130cd1840db177ac02f8d1513 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Sun, 3 Aug 2025 18:09:43 +0100 Subject: [PATCH 17/58] Update "show only" filter content This updates the content in the patient search which allows you to selecting viewing only patients with a certain criteria. At the moment it's only for patients with missing NHS numbers, but it will soon include an option for archived patients. Jira-Issue: MAV-1513 --- app/components/app_patient_search_form_component.rb | 4 ++-- spec/features/patient_search_spec.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/app_patient_search_form_component.rb b/app/components/app_patient_search_form_component.rb index 48abbccca0..4d1ba08d73 100644 --- a/app/components/app_patient_search_form_component.rb +++ b/app/components/app_patient_search_form_component.rb @@ -143,13 +143,13 @@ class AppPatientSearchFormComponent < ViewComponent::Base - <%= f.govuk_check_boxes_fieldset :missing_nhs_number, multiple: false, legend: { text: "Options", size: "s" } do %> + <%= f.govuk_check_boxes_fieldset :show_only, multiple: false, legend: { text: "Show only", size: "s" } do %> <%= f.govuk_check_box :missing_nhs_number, 1, 0, checked: form.missing_nhs_number, multiple: false, link_errors: true, - label: { text: "Missing NHS number" } %> + label: { text: "Children missing an NHS number" } %> <% end %> <% if show_buttons_in_details? %> diff --git a/spec/features/patient_search_spec.rb b/spec/features/patient_search_spec.rb index a82ed7279c..9b0276a455 100644 --- a/spec/features/patient_search_spec.rb +++ b/spec/features/patient_search_spec.rb @@ -169,7 +169,7 @@ def then_i_see_patients_starting_with_aa def when_i_search_for_patients_without_nhs_numbers find(".nhsuk-details__summary").click - check "Missing NHS number" + check "Children missing an NHS number" click_button "Update results" end From b3a97c7d985130e2f6588fcee0cd46c466c932a3 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 30 Jul 2025 14:39:18 +0100 Subject: [PATCH 18/58] Add ability to see archived patients When viewing a list of patients with a search form this ensures that the user has the option to view any archived patients that may exist. Jira-Issue: MAV-1513 --- app/components/app_child_summary_component.rb | 32 +++- app/components/app_patient_card_component.rb | 15 +- .../app_patient_search_form_component.rb | 10 +- .../concerns/patient_search_form_concern.rb | 2 + app/controllers/patients_controller.rb | 5 +- app/forms/patient_search_form.rb | 12 +- app/models/patient_session.rb | 4 + app/views/patients/show.html.erb | 2 +- .../app_child_summary_component_spec.rb | 19 +++ spec/features/archive_children_spec.rb | 66 +++++++++ spec/forms/patient_search_form_spec.rb | 139 ++++++++++++++++-- 11 files changed, 279 insertions(+), 27 deletions(-) create mode 100644 spec/features/archive_children_spec.rb diff --git a/app/components/app_child_summary_component.rb b/app/components/app_child_summary_component.rb index 53d6d90cbc..ed390a4c60 100644 --- a/app/components/app_child_summary_component.rb +++ b/app/components/app_child_summary_component.rb @@ -1,10 +1,17 @@ # frozen_string_literal: true class AppChildSummaryComponent < ViewComponent::Base - def initialize(child, show_parents: false, change_links: {}, remove_links: {}) + def initialize( + child, + team: nil, + show_parents: false, + change_links: {}, + remove_links: {} + ) super @child = child + @team = team @show_parents = show_parents @change_links = change_links @remove_links = remove_links @@ -25,6 +32,14 @@ def call ) end end + + if archive_reason + summary_list.with_row do |row| + row.with_key { "Archive reason" } + row.with_value { format_archive_reason } + end + end + summary_list.with_row do |row| row.with_key { "Full name" } row.with_value { format_full_name } @@ -112,10 +127,25 @@ def call def academic_year = AcademicYear.current + def archive_reason + @archive_reason ||= + (ArchiveReason.find_by(team: @team, patient: @child) if @team) + end + def format_nhs_number highlight_if(helpers.patient_nhs_number(@child), @child.nhs_number_changed?) end + def format_archive_reason + type_string = archive_reason.human_enum_name(:type) + + if archive_reason.other? + "#{type_string}: #{archive_reason.other_details}" + else + type_string + end + end + def format_full_name highlight_if( @child.full_name, diff --git a/app/components/app_patient_card_component.rb b/app/components/app_patient_card_component.rb index 971029587b..bbd7d1d6be 100644 --- a/app/components/app_patient_card_component.rb +++ b/app/components/app_patient_card_component.rb @@ -23,16 +23,25 @@ class AppPatientCardComponent < ViewComponent::Base ) %> <% end %> - <%= render AppChildSummaryComponent.new(patient, show_parents: true, change_links:, remove_links:) %> + <%= render AppChildSummaryComponent.new( + patient, team:, show_parents: true, change_links:, remove_links: + ) %> <%= content %> <% end %> ERB - def initialize(patient, change_links: {}, remove_links: {}, heading_level: 3) + def initialize( + patient, + team: nil, + change_links: {}, + remove_links: {}, + heading_level: 3 + ) super @patient = patient + @team = team @change_links = change_links @remove_links = remove_links @heading_level = heading_level @@ -40,5 +49,5 @@ def initialize(patient, change_links: {}, remove_links: {}, heading_level: 3) private - attr_reader :patient, :change_links, :remove_links, :heading_level + attr_reader :patient, :team, :change_links, :remove_links, :heading_level end diff --git a/app/components/app_patient_search_form_component.rb b/app/components/app_patient_search_form_component.rb index 4d1ba08d73..c5a06093e2 100644 --- a/app/components/app_patient_search_form_component.rb +++ b/app/components/app_patient_search_form_component.rb @@ -144,6 +144,13 @@ class AppPatientSearchFormComponent < ViewComponent::Base <%= f.govuk_check_boxes_fieldset :show_only, multiple: false, legend: { text: "Show only", size: "s" } do %> + <%= f.govuk_check_box :archived, + 1, 0, + checked: form.archived, + multiple: false, + link_errors: true, + label: { text: "Archived records" } %> + <%= f.govuk_check_box :missing_nhs_number, 1, 0, checked: form.missing_nhs_number, @@ -215,7 +222,8 @@ def initialize( def open_details? @form.date_of_birth_year.present? || @form.date_of_birth_month.present? || - @form.date_of_birth_day.present? || @form.missing_nhs_number + @form.date_of_birth_day.present? || @form.missing_nhs_number || + @form.archived end def show_buttons_in_details? diff --git a/app/controllers/concerns/patient_search_form_concern.rb b/app/controllers/concerns/patient_search_form_concern.rb index 0fbbd98768..d81f45dcac 100644 --- a/app/controllers/concerns/patient_search_form_concern.rb +++ b/app/controllers/concerns/patient_search_form_concern.rb @@ -8,6 +8,7 @@ module PatientSearchFormConcern def set_patient_search_form @form = PatientSearchForm.new( + current_user:, request_path: request.path, request_session: session, session: @session, @@ -20,6 +21,7 @@ def set_patient_search_form def patient_search_form_params params.permit( :_clear, + :archived, :date_of_birth_day, :date_of_birth_month, :date_of_birth_year, diff --git a/app/controllers/patients_controller.rb b/app/controllers/patients_controller.rb index 863f61945d..58601afaa1 100644 --- a/app/controllers/patients_controller.rb +++ b/app/controllers/patients_controller.rb @@ -8,10 +8,7 @@ class PatientsController < ApplicationController before_action :record_access_log_entry, only: %i[show log] def index - patients = - @form.apply( - policy_scope(Patient).includes(:school).not_archived(team: current_team) - ) + patients = @form.apply(policy_scope(Patient).includes(:school)) @pagy, @patients = pagy(patients) diff --git a/app/forms/patient_search_form.rb b/app/forms/patient_search_form.rb index 667d96e7f8..495dece32f 100644 --- a/app/forms/patient_search_form.rb +++ b/app/forms/patient_search_form.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true class PatientSearchForm < SearchForm + attr_accessor :current_user attr_writer :academic_year + attribute :archived, :boolean attribute :consent_statuses, array: true attribute :date_of_birth_day, :integer attribute :date_of_birth_month, :integer @@ -17,7 +19,8 @@ class PatientSearchForm < SearchForm attribute :vaccine_method, :string attribute :year_groups, array: true - def initialize(session: nil, **attributes) + def initialize(current_user:, session: nil, **attributes) + @current_user = current_user @session = session super(**attributes) end @@ -46,6 +49,7 @@ def programmes def apply(scope) scope = filter_name(scope) scope = filter_year_groups(scope) + scope = filter_archived(scope) scope = filter_date_of_birth_year(scope) scope = filter_nhs_number(scope) scope = filter_programmes(scope) @@ -64,6 +68,8 @@ def apply(scope) def academic_year = @session&.academic_year || @academic_year || AcademicYear.current + def team = @current_user.selected_team + def filter_name(scope) q.present? ? scope.search_by_name(q) : scope end @@ -76,6 +82,10 @@ def filter_year_groups(scope) end end + def filter_archived(scope) + archived ? scope.archived(team:) : scope + end + def filter_date_of_birth_year(scope) if date_of_birth_year.present? scope = scope.search_by_date_of_birth_year(date_of_birth_year) diff --git a/app/models/patient_session.rb b/app/models/patient_session.rb index 65d91c5d7d..d2dc1fad7d 100644 --- a/app/models/patient_session.rb +++ b/app/models/patient_session.rb @@ -72,6 +72,10 @@ class PatientSession < ApplicationRecord has_and_belongs_to_many :immunisation_imports + scope :archived, ->(team:) { merge(Patient.archived(team:)) } + + scope :not_archived, ->(team:) { merge(Patient.not_archived(team:)) } + scope :notification_not_sent, ->(session_date) do where.not( diff --git a/app/views/patients/show.html.erb b/app/views/patients/show.html.erb index 064ee71a11..828c560af8 100644 --- a/app/views/patients/show.html.erb +++ b/app/views/patients/show.html.erb @@ -14,7 +14,7 @@ nav.with_item(href: log_patient_path(@patient), text: "Activity log") end %> -<%= render AppPatientCardComponent.new(@patient) do %> +<%= render AppPatientCardComponent.new(@patient, team: current_team) do %> <%= govuk_button_link_to "Edit child record", edit_patient_path(@patient), secondary: true %> <% end %> diff --git a/spec/components/app_child_summary_component_spec.rb b/spec/components/app_child_summary_component_spec.rb index 26d74f3950..bc76cb98ad 100644 --- a/spec/components/app_child_summary_component_spec.rb +++ b/spec/components/app_child_summary_component_spec.rb @@ -105,4 +105,23 @@ it { should have_text("DOE, John") } end + + context "when archived" do + let(:component) { described_class.new(patient, team:) } + + let(:team) { create(:team) } + + before do + create( + :archive_reason, + :other, + patient:, + team:, + other_details: "Some details." + ) + end + + it { should have_text("Archive reason") } + it { should have_text("Other: Some details.") } + end end diff --git a/spec/features/archive_children_spec.rb b/spec/features/archive_children_spec.rb new file mode 100644 index 0000000000..9bd64c2f81 --- /dev/null +++ b/spec/features/archive_children_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +describe "Archive children" do + before do + given_an_team_exists + and_i_am_signed_in + end + + scenario "View archived patients" do + given_an_unarchived_patient_exists + and_an_archived_patient_exists + + when_i_visit_the_children_page + then_i_see_both_patients + + when_i_filter_to_see_only_archived_patients + then_i_see_only_the_archived_patient + end + + def given_an_team_exists + programmes = [create(:programme, :flu)] + @team = create(:team, programmes:) + + @session = create(:session, team: @team, programmes:) + end + + def and_i_am_signed_in + @user = create(:nurse, team: @team) + sign_in @user + end + + def given_an_unarchived_patient_exists + @unarchived_patient = create(:patient, session: @session) + end + + def and_an_archived_patient_exists + @archived_patient = create(:patient) + create( + :archive_reason, + :imported_in_error, + patient: @archived_patient, + team: @team + ) + end + + def when_i_visit_the_children_page + visit patients_path + end + + def then_i_see_both_patients + expect(page).to have_content("2 children") + expect(page).to have_content(@unarchived_patient.full_name) + expect(page).to have_content(@archived_patient.full_name) + end + + def when_i_filter_to_see_only_archived_patients + find(".nhsuk-details__summary").click + check "Archived records" + click_on "Search" + end + + def then_i_see_only_the_archived_patient + expect(page).to have_content("1 child") + expect(page).to have_content(@archived_patient.full_name) + end +end diff --git a/spec/forms/patient_search_form_spec.rb b/spec/forms/patient_search_form_spec.rb index 63cfa233ad..9ad6eaf9be 100644 --- a/spec/forms/patient_search_form_spec.rb +++ b/spec/forms/patient_search_form_spec.rb @@ -2,13 +2,23 @@ describe PatientSearchForm do subject(:form) do - described_class.new(request_session:, request_path:, session:, **params) + described_class.new( + current_user:, + request_session:, + request_path:, + session:, + **params + ) end + let(:current_user) { create(:user, teams: [team]) } let(:request_session) { {} } let(:request_path) { "/patients" } let(:session) { nil } + let(:team) { create(:team) } + + let(:archived) { nil } let(:consent_statuses) { nil } let(:date_of_birth_day) { Date.current.day } let(:date_of_birth_month) { Date.current.month } @@ -25,6 +35,7 @@ let(:params) do { + archived:, consent_statuses:, date_of_birth_day:, date_of_birth_month:, @@ -50,6 +61,51 @@ expect { form.apply(scope) }.not_to raise_error end + context "filtering on archived" do + let(:consent_statuses) { nil } + let(:date_of_birth_day) { nil } + let(:date_of_birth_month) { nil } + let(:date_of_birth_year) { nil } + let(:missing_nhs_number) { nil } + let(:programme_status) { nil } + let(:q) { nil } + let(:register_status) { nil } + let(:session_status) { nil } + let(:triage_status) { nil } + let(:year_groups) { nil } + + let!(:unarchived_patient) { create(:patient) } + let!(:archived_patient) { create(:patient) } + + before do + create(:archive_reason, :deceased, team:, patient: archived_patient) + end + + context "when not filtering on archived patients" do + let(:archived) { nil } + + it "includes the unarchived patient" do + expect(form.apply(scope)).to include(unarchived_patient) + end + + it "includes the archived patient" do + expect(form.apply(scope)).to include(archived_patient) + end + end + + context "when filtering on archived patients" do + let(:archived) { true } + + it "doesn't include the unarchived patient" do + expect(form.apply(scope)).not_to include(unarchived_patient) + end + + it "includes the unarchived patient" do + expect(form.apply(scope)).to include(archived_patient) + end + end + end + context "filtering on date of birth" do let(:consent_statuses) { nil } let(:date_of_birth_day) { nil } @@ -398,62 +454,100 @@ context "when _clear param is present" do it "only clears filters for the current path" do - described_class.new(request_path:, request_session:, "q" => "John") + described_class.new( + current_user:, + request_path:, + request_session:, + "q" => "John" + ) described_class.new( + current_user:, request_path: another_path, request_session:, q: "Jane" ) - described_class.new(request_path:, request_session:, "_clear" => "true") + described_class.new( + current_user:, + request_path:, + request_session:, + "_clear" => "true" + ) - form1 = described_class.new(request_session:, request_path:) + form1 = + described_class.new(current_user:, request_session:, request_path:) expect(form1.q).to be_nil form2 = - described_class.new(request_session:, request_path: another_path) + described_class.new( + current_user:, + request_session:, + request_path: another_path + ) expect(form2.q).to eq("Jane") end end context "when filters are present in params" do it "persists filters to be loaded in subsequent requests" do - described_class.new(q: "John", request_session:, request_path:) + described_class.new( + current_user:, + q: "John", + request_session:, + request_path: + ) - form = described_class.new(request_session:, request_path:) + form = + described_class.new(current_user:, request_session:, request_path:) expect(form.q).to eq("John") end it "overwrites previously stored filters" do - described_class.new(q: "John", request_session:, request_path:) + described_class.new( + current_user:, + q: "John", + request_session:, + request_path: + ) - form1 = described_class.new(q: "Jane", request_session:, request_path:) + form1 = + described_class.new( + current_user:, + q: "Jane", + request_session:, + request_path: + ) expect(form1.q).to eq("Jane") - form2 = described_class.new(request_session:, request_path:) + form2 = + described_class.new(current_user:, request_session:, request_path:) expect(form2.q).to eq("Jane") end it "overrides session filters when 'Any' option is selected (empty string)" do described_class.new( + current_user:, consent_statuses: %w[given], request_session:, request_path: ) - form1 = described_class.new(request_session:, request_path:) + form1 = + described_class.new(current_user:, request_session:, request_path:) expect(form1.consent_statuses).to eq(%w[given]) form2 = described_class.new( + current_user:, consent_statuses: nil, request_session:, request_path: ) expect(form2.consent_statuses).to eq([]) - form3 = described_class.new(request_session:, request_path:) + form3 = + described_class.new(current_user:, request_session:, request_path:) expect(form3.consent_statuses).to eq([]) end end @@ -461,6 +555,7 @@ context "when no filters are present in params but exist in session" do before do described_class.new( + current_user:, q: "John", year_groups: %w[8 11], consent_statuses: %w[given], @@ -470,7 +565,8 @@ end it "loads filters from the session" do - form = described_class.new(request_session:, request_path:) + form = + described_class.new(current_user:, request_session:, request_path:) expect(form.q).to eq("John") expect(form.year_groups).to eq([8, 11]) @@ -480,18 +576,29 @@ context "with path-specific filters" do it "maintains separate filters for different paths" do - described_class.new(q: "John", request_session:, request_path:) described_class.new( + current_user:, + q: "John", + request_session:, + request_path: + ) + described_class.new( + current_user:, q: "Jane", request_session:, request_path: another_path ) - form1 = described_class.new(request_session:, request_path:) + form1 = + described_class.new(current_user:, request_session:, request_path:) expect(form1.q).to eq("John") form2 = - described_class.new(request_session:, request_path: another_path) + described_class.new( + current_user:, + request_session:, + request_path: another_path + ) expect(form2.q).to eq("Jane") end end From 3cbc5560c00ee201d85af587859e89434e0ea3a1 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 30 Jul 2025 06:55:54 +0100 Subject: [PATCH 19/58] Remove "Remove from cohort" functionality This feature has been replaced with the archive functionality that has been added in the previous commits. Jira-Issue: MAV-1506 --- ...pp_patient_cohort_table_component.html.erb | 30 ------------------- .../app_patient_cohort_table_component.rb | 23 -------------- app/controllers/patients_controller.rb | 26 ---------------- app/views/patients/show.html.erb | 5 ---- config/routes.rb | 2 +- ...app_patient_cohort_table_component_spec.rb | 25 ---------------- spec/features/manage_children_spec.rb | 22 -------------- 7 files changed, 1 insertion(+), 132 deletions(-) delete mode 100644 app/components/app_patient_cohort_table_component.html.erb delete mode 100644 app/components/app_patient_cohort_table_component.rb delete mode 100644 spec/components/app_patient_cohort_table_component_spec.rb diff --git a/app/components/app_patient_cohort_table_component.html.erb b/app/components/app_patient_cohort_table_component.html.erb deleted file mode 100644 index d441d39960..0000000000 --- a/app/components/app_patient_cohort_table_component.html.erb +++ /dev/null @@ -1,30 +0,0 @@ -<% if team %> - <%= govuk_table(html_attributes: { - class: "nhsuk-table-responsive", - }) do |table| %> - <% table.with_head do |head| %> - <% head.with_row do |row| %> - <% row.with_cell(text: "Name") %> - <% row.with_cell(text: "Actions") %> - <% end %> - <% end %> - - <% table.with_body do |body| %> - <% body.with_row do |row| %> - <% row.with_cell do %> - Name - <%= helpers.format_year_group(year_group) %> - <% end %> - <% row.with_cell do %> - Actions - <%= form_with model: @patient, builder: GOVUKDesignSystemFormBuilder::FormBuilder do |f| %> - <%= f.hidden_field :team_id, value: team.id %> - <%= f.govuk_submit "Remove from cohort", class: "app-button--secondary-warning app-button--small" %> - <% end %> - <% end %> - <% end %> - <% end %> - <% end %> -<% else %> -

No cohorts

-<% end %> diff --git a/app/components/app_patient_cohort_table_component.rb b/app/components/app_patient_cohort_table_component.rb deleted file mode 100644 index 00e956072b..0000000000 --- a/app/components/app_patient_cohort_table_component.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -class AppPatientCohortTableComponent < ViewComponent::Base - def initialize(patient, current_user:) - super - - @patient = patient - @current_user = current_user - end - - private - - attr_reader :patient, :current_user - - delegate :year_group, to: :patient - - def team - @team ||= - if current_user.selected_team.patients.include?(patient) - current_user.selected_team - end - end -end diff --git a/app/controllers/patients_controller.rb b/app/controllers/patients_controller.rb index 58601afaa1..07bf517087 100644 --- a/app/controllers/patients_controller.rb +++ b/app/controllers/patients_controller.rb @@ -30,32 +30,6 @@ def edit render layout: "full" end - def update - team_id = params.dig(:patient, :team_id).presence - - ActiveRecord::Base.transaction do - @patient - .patient_sessions - .joins(:session) - .where(session: { team_id: }) - .destroy_all_if_safe - end - - path = - ( - if policy_scope(Patient).include?(@patient) - patient_path(@patient) - else - patients_path - end - ) - - redirect_to path, - flash: { - success: "#{@patient.full_name} removed from cohort" - } - end - private def set_patient diff --git a/app/views/patients/show.html.erb b/app/views/patients/show.html.erb index 828c560af8..0449212b15 100644 --- a/app/views/patients/show.html.erb +++ b/app/views/patients/show.html.erb @@ -18,11 +18,6 @@ <%= govuk_button_link_to "Edit child record", edit_patient_path(@patient), secondary: true %> <% end %> -<%= render AppCardComponent.new(section: true) do |card| %> - <% card.with_heading { "Cohorts" } %> - <%= render AppPatientCohortTableComponent.new(@patient, current_user:) %> -<% end %> - <%= render AppCardComponent.new(section: true) do |card| %> <% card.with_heading { "Sessions" } %> <%= render AppPatientSessionTableComponent.new(@patient_sessions) %> diff --git a/config/routes.rb b/config/routes.rb index 5fac7dc0a3..b16146c3c5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -138,7 +138,7 @@ resources :notifications, only: :create - resources :patients, only: %i[index show edit update] do + resources :patients, only: %i[index show edit] do post "", action: :index, on: :collection resources :parent_relationships, diff --git a/spec/components/app_patient_cohort_table_component_spec.rb b/spec/components/app_patient_cohort_table_component_spec.rb deleted file mode 100644 index 18e77d1095..0000000000 --- a/spec/components/app_patient_cohort_table_component_spec.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -describe AppPatientCohortTableComponent do - subject { render_inline(component) } - - let(:component) { described_class.new(patient, current_user:) } - - let(:current_user) { create(:nurse) } - - context "without a cohort" do - let(:patient) { create(:patient) } - - it { should have_content("No cohorts") } - end - - context "with a cohort" do - let(:team) { current_user.selected_team } - let(:session) { create(:session, team:) } - - let(:patient) { create(:patient, year_group: 8, session:) } - - it { should have_content("Year 8") } - it { should have_content("Remove from cohort") } - end -end diff --git a/spec/features/manage_children_spec.rb b/spec/features/manage_children_spec.rb index 8e9a787d52..ddf6d8a221 100644 --- a/spec/features/manage_children_spec.rb +++ b/spec/features/manage_children_spec.rb @@ -89,19 +89,6 @@ and_the_vaccination_record_is_deleted_from_the_nhs end - scenario "Removing a child from a cohort" do - given_patients_exist - - when_i_click_on_children - and_i_click_on_a_child - then_i_see_the_child - and_i_see_the_cohort - - when_i_click_on_remove_from_cohort - then_i_see_the_children - and_i_see_a_removed_from_cohort_message - end - scenario "Viewing important notices" do when_i_go_to_the_imports_page then_i_cannot_see_notices @@ -249,7 +236,6 @@ def when_i_click_on_a_child def then_i_see_the_child expect(page).to have_title("JS") expect(page).to have_content("SMITH, John") - expect(page).to have_content("Cohorts") expect(page).to have_content("Sessions") end @@ -326,14 +312,6 @@ def and_i_see_the_cohort expect(page).not_to have_content("No sessions") end - def when_i_click_on_remove_from_cohort - click_on "Remove from cohort" - end - - def and_i_see_a_removed_from_cohort_message - expect(page).to have_content("removed from cohort") - end - def when_i_go_to_the_dashboard sign_in @team.users.first From a5dd82cec24341e6c62832d61e8569b02cba36d7 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 30 Jul 2025 08:18:24 +0100 Subject: [PATCH 20/58] Add PatientMergeForm This adds a new form which handles the logic related to displaying a page that allows the user to merge two patient records. The reason behind this refactor is that we need to support merging patient records in the archive form. Jira-Issue: MAV-1506 --- app/controllers/patients/edit_controller.rb | 33 +++++++++-------- app/forms/patient_merge_form.rb | 36 +++++++++++++++++++ app/lib/patient_merger.rb | 4 +-- .../patients/edit/nhs_number_merge.html.erb | 4 +-- config/locales/en.yml | 6 ++++ spec/forms/patient_merge_form_spec.rb | 10 ++++++ 6 files changed, 71 insertions(+), 22 deletions(-) create mode 100644 app/forms/patient_merge_form.rb create mode 100644 spec/forms/patient_merge_form_spec.rb diff --git a/app/controllers/patients/edit_controller.rb b/app/controllers/patients/edit_controller.rb index 9d2d15dbfb..b65e6a42ff 100644 --- a/app/controllers/patients/edit_controller.rb +++ b/app/controllers/patients/edit_controller.rb @@ -2,6 +2,8 @@ class Patients::EditController < ApplicationController before_action :set_patient + before_action :set_patient_merge_form, except: :edit_nhs_number + before_action :set_existing_patient, except: :edit_nhs_number def edit_nhs_number render :nhs_number @@ -12,7 +14,7 @@ def update_nhs_number redirect_to edit_patient_path(@patient) and return unless @patient.changed? - render :nhs_number_merge and return if existing_patient + render :nhs_number_merge and return if @existing_patient @patient.invalidated_at = nil @@ -26,9 +28,11 @@ def update_nhs_number end def update_nhs_number_merge - PatientMerger.call(to_keep: existing_patient, to_destroy: @patient) - - redirect_to edit_patient_path(existing_patient) + if @form.save + redirect_to edit_patient_path(@existing_patient) + else + render :nhs_number_merge, status: :unprocessable_entity + end end private @@ -40,21 +44,16 @@ def set_patient ) end - def existing_patient - @existing_patient ||= - if nhs_number.present? - policy_scope(Patient).includes(parent_relationships: :parent).find_by( - nhs_number: - ) || - Patient - .where - .missing(:patient_sessions) - .includes(parent_relationships: :parent) - .find_by(nhs_number:) - end + def set_patient_merge_form + @form = PatientMergeForm.new(current_user:, patient: @patient, nhs_number:) + end + + def set_existing_patient + @existing_patient = @form.existing_patient end def nhs_number - params.dig(:patient, :nhs_number) + params.dig(:patient, :nhs_number) || + params.dig(:patient_merge_form, :nhs_number) end end diff --git a/app/forms/patient_merge_form.rb b/app/forms/patient_merge_form.rb new file mode 100644 index 0000000000..249fa58cd5 --- /dev/null +++ b/app/forms/patient_merge_form.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class PatientMergeForm + include ActiveModel::Model + include ActiveModel::Attributes + + attr_accessor :current_user, :patient + + attribute :nhs_number, :string + + validates :nhs_number, nhs_number: true + + def existing_patient + @existing_patient ||= + if nhs_number.present? + patient_policy_scope.find_by(nhs_number:) || + Patient.where.missing(:patient_sessions).find_by(nhs_number:) + end + end + + def save + return false if invalid? + + if existing_patient + PatientMerger.call(to_keep: existing_patient, to_destroy: patient) + end + + true + end + + private + + def patient_policy_scope + PatientPolicy::Scope.new(current_user, Patient).resolve + end +end diff --git a/app/lib/patient_merger.rb b/app/lib/patient_merger.rb index ac411ce34a..b899a1ac7a 100644 --- a/app/lib/patient_merger.rb +++ b/app/lib/patient_merger.rb @@ -121,9 +121,7 @@ def call .find_each(&:sync_to_nhs_immunisations_api) end - def self.call(*args, **kwargs) - new(*args, **kwargs).call - end + def self.call(...) = new(...).call private_class_method :new diff --git a/app/views/patients/edit/nhs_number_merge.html.erb b/app/views/patients/edit/nhs_number_merge.html.erb index 4205076319..7e2d1f654b 100644 --- a/app/views/patients/edit/nhs_number_merge.html.erb +++ b/app/views/patients/edit/nhs_number_merge.html.erb @@ -15,8 +15,8 @@ <%= render AppPatientCardComponent.new(@existing_patient) %> -<%= form_with model: @patient, url: edit_nhs_number_merge_patient_path(@patient), method: :put do |f| %> - <%= f.hidden_field :nhs_number, value: @existing_patient.nhs_number %> +<%= form_with model: @form, url: edit_nhs_number_merge_patient_path(@patient), method: :put do |f| %> + <%= f.hidden_field :nhs_number %>
<%= f.govuk_submit "Merge records" %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 1081212639..06be139afe 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -108,6 +108,12 @@ en: attributes: apply_changes: inclusion: Choose which record to keep + patient_merge_form: + attributes: + nhs_number: + blank: Enter an NHS number + invalid: Enter a valid NHS number + wrong_length: Enter a valid NHS number with 10 characters school_move_form: attributes: action: diff --git a/spec/forms/patient_merge_form_spec.rb b/spec/forms/patient_merge_form_spec.rb new file mode 100644 index 0000000000..6d066942ff --- /dev/null +++ b/spec/forms/patient_merge_form_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +describe PatientMergeForm do + subject(:form) { described_class.new } + + describe "validations" do + it { should validate_presence_of(:nhs_number) } + it { should validate_length_of(:nhs_number).is_equal_to(10) } + end +end From 8be10473d889eb78961828f01bfab523baeff5c4 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 30 Jul 2025 08:43:12 +0100 Subject: [PATCH 21/58] Add PatientArchiveForm This adds a new form which handles the logic related to archiving a patient, specifically choosing the type of archival and then performing the right action accordingly. Jira-Issue: MAV-1506 --- .../concerns/patient_merge_form_concern.rb | 34 +++++++++++++++ app/forms/patient_archive_form.rb | 42 +++++++++++++++++++ app/forms/patient_merge_form.rb | 29 +------------ config/locales/en.yml | 11 +++++ spec/forms/patient_archive_form_spec.rb | 27 ++++++++++++ 5 files changed, 115 insertions(+), 28 deletions(-) create mode 100644 app/forms/concerns/patient_merge_form_concern.rb create mode 100644 app/forms/patient_archive_form.rb create mode 100644 spec/forms/patient_archive_form_spec.rb diff --git a/app/forms/concerns/patient_merge_form_concern.rb b/app/forms/concerns/patient_merge_form_concern.rb new file mode 100644 index 0000000000..0bb30ea4b9 --- /dev/null +++ b/app/forms/concerns/patient_merge_form_concern.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module PatientMergeFormConcern + extend ActiveSupport::Concern + + include ActiveModel::Model + include ActiveModel::Attributes + + included { attribute :nhs_number, :string } + + def existing_patient + @existing_patient ||= + if nhs_number.present? + patient_policy_scope.find_by(nhs_number:) || + Patient.where.missing(:patient_sessions).find_by(nhs_number:) + end + end + + def save + return false if invalid? + + if existing_patient + PatientMerger.call(to_keep: existing_patient, to_destroy: patient) + end + + true + end + + private + + def patient_policy_scope + PatientPolicy::Scope.new(current_user, Patient).resolve + end +end diff --git a/app/forms/patient_archive_form.rb b/app/forms/patient_archive_form.rb new file mode 100644 index 0000000000..871129db75 --- /dev/null +++ b/app/forms/patient_archive_form.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class PatientArchiveForm + include PatientMergeFormConcern + + attr_accessor :current_user, :archive_reason + + attribute :other_details, :string + attribute :type, :string + + validates :nhs_number, nhs_number: true, if: :duplicate? + + validates :other_details, + presence: true, + length: { + maximum: 300 + }, + if: :other? + + validates :type, + inclusion: { + in: %w[duplicate imported_in_error moved_out_of_area other] + } + + def save + return false unless valid? + + if duplicate? + super + elsif other? + archive_reason.update!(type:, other_details:) + else + archive_reason.update!(type:, other_details: "") + end + end + + def duplicate? = type == "duplicate" + + def other? = type == "other" + + delegate :organisation, :patient, to: :archive_reason +end diff --git a/app/forms/patient_merge_form.rb b/app/forms/patient_merge_form.rb index 249fa58cd5..2795d2f888 100644 --- a/app/forms/patient_merge_form.rb +++ b/app/forms/patient_merge_form.rb @@ -1,36 +1,9 @@ # frozen_string_literal: true class PatientMergeForm - include ActiveModel::Model - include ActiveModel::Attributes + include PatientMergeFormConcern attr_accessor :current_user, :patient - attribute :nhs_number, :string - validates :nhs_number, nhs_number: true - - def existing_patient - @existing_patient ||= - if nhs_number.present? - patient_policy_scope.find_by(nhs_number:) || - Patient.where.missing(:patient_sessions).find_by(nhs_number:) - end - end - - def save - return false if invalid? - - if existing_patient - PatientMerger.call(to_keep: existing_patient, to_destroy: patient) - end - - true - end - - private - - def patient_policy_scope - PatientPolicy::Scope.new(current_user, Patient).resolve - end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 06be139afe..65b11a7933 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -108,6 +108,17 @@ en: attributes: apply_changes: inclusion: Choose which record to keep + patient_archive_form: + attributes: + other_details: + blank: Enter details + too_long: Enter details that is less than %{count} characters long + nhs_number: + blank: Enter an NHS number + invalid: Enter a valid NHS number + wrong_length: Enter a valid NHS number with 10 characters + type: + inclusion: Choose why you want to archive this record patient_merge_form: attributes: nhs_number: diff --git a/spec/forms/patient_archive_form_spec.rb b/spec/forms/patient_archive_form_spec.rb new file mode 100644 index 0000000000..cdacadc0b5 --- /dev/null +++ b/spec/forms/patient_archive_form_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +describe PatientArchiveForm do + subject(:form) { described_class.new } + + describe "validations" do + it do + expect(form).to validate_inclusion_of(:type).in_array( + %w[duplicate imported_in_error moved_out_of_area other] + ) + end + + context "when type is duplicate" do + before { form.type = "duplicate" } + + it { should validate_presence_of(:nhs_number) } + it { should validate_length_of(:nhs_number).is_equal_to(10) } + end + + context "when type is other" do + before { form.type = "other" } + + it { should validate_presence_of(:other_details) } + it { should validate_length_of(:other_details).is_at_most(300) } + end + end +end From 788eeffd116264cdc7bbf264ef084f3ecb4cb536 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Sun, 3 Aug 2025 19:27:59 +0100 Subject: [PATCH 22/58] Add archive feature This adds a new controller and view to handle the feature allowing users to archive patients using the `PatientArchiveForm` added in the previous commit. Jira-Issue: MAV-1506 --- .../patients/archive_controller.rb | 39 ++++ app/controllers/patients/base_controller.rb | 11 ++ app/controllers/patients/edit_controller.rb | 10 +- app/forms/patient_archive_form.rb | 14 +- app/models/patient.rb | 12 +- app/views/patients/archive/new.html.erb | 36 ++++ app/views/patients/show.html.erb | 7 +- config/routes.rb | 5 + spec/features/archive_children_spec.rb | 175 +++++++++++++++++- 9 files changed, 290 insertions(+), 19 deletions(-) create mode 100644 app/controllers/patients/archive_controller.rb create mode 100644 app/controllers/patients/base_controller.rb create mode 100644 app/views/patients/archive/new.html.erb diff --git a/app/controllers/patients/archive_controller.rb b/app/controllers/patients/archive_controller.rb new file mode 100644 index 0000000000..af3d1550fb --- /dev/null +++ b/app/controllers/patients/archive_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class Patients::ArchiveController < Patients::BaseController + before_action :set_archive_reason + + def new + @form = PatientArchiveForm.new + end + + def create + @form = + PatientArchiveForm.new( + archive_reason: @archive_reason, + current_user:, + **patient_archive_form_params + ) + + if @form.save + flash[:success] = "Child record archived" + redirect_to patient_path( + @form.duplicate? ? @form.existing_patient : @patient + ) + else + render :new, status: :unprocessable_entity + end + end + + private + + def set_archive_reason + @archive_reason = ArchiveReason.find_or_create_by(team:, patient: @patient) + end + + def team = current_user.selected_team + + def patient_archive_form_params + params.expect(patient_archive_form: %i[nhs_number type other_details]) + end +end diff --git a/app/controllers/patients/base_controller.rb b/app/controllers/patients/base_controller.rb new file mode 100644 index 0000000000..443f20fb6d --- /dev/null +++ b/app/controllers/patients/base_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Patients::BaseController < ApplicationController + before_action :set_patient + + private + + def set_patient + @patient = policy_scope(Patient).find(params[:patient_id] || params[:id]) + end +end diff --git a/app/controllers/patients/edit_controller.rb b/app/controllers/patients/edit_controller.rb index b65e6a42ff..dde6bbfa58 100644 --- a/app/controllers/patients/edit_controller.rb +++ b/app/controllers/patients/edit_controller.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true -class Patients::EditController < ApplicationController - before_action :set_patient +class Patients::EditController < Patients::BaseController before_action :set_patient_merge_form, except: :edit_nhs_number before_action :set_existing_patient, except: :edit_nhs_number @@ -37,13 +36,6 @@ def update_nhs_number_merge private - def set_patient - @patient = - policy_scope(Patient).includes(parent_relationships: :parent).find( - params[:id] - ) - end - def set_patient_merge_form @form = PatientMergeForm.new(current_user:, patient: @patient, nhs_number:) end diff --git a/app/forms/patient_archive_form.rb b/app/forms/patient_archive_form.rb index 871129db75..295ed4d040 100644 --- a/app/forms/patient_archive_form.rb +++ b/app/forms/patient_archive_form.rb @@ -27,10 +27,16 @@ def save if duplicate? super - elsif other? - archive_reason.update!(type:, other_details:) else - archive_reason.update!(type:, other_details: "") + ActiveRecord::Base.transaction do + if other? + archive_reason.update!(type:, other_details:) + else + archive_reason.update!(type:, other_details: "") + end + + patient.clear_pending_sessions!(team:) + end end end @@ -38,5 +44,5 @@ def duplicate? = type == "duplicate" def other? = type == "other" - delegate :organisation, :patient, to: :archive_reason + delegate :team, :patient, to: :archive_reason end diff --git a/app/models/patient.rb b/app/models/patient.rb index a223074ca0..e7b6e12ef1 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -458,6 +458,14 @@ def dup_for_pending_changes end end + def clear_pending_sessions!(team: nil) + sessions = pending_sessions + + sessions = sessions.where(team_id: team.id) unless team.nil? + + patient_sessions.where(session: sessions).destroy_all_if_safe + end + def self.from_consent_form(consent_form) new( address_line_1: consent_form.address_line_1, @@ -508,10 +516,6 @@ def destroy_childless_parents end end - def clear_pending_sessions! - patient_sessions.where(session: pending_sessions).destroy_all_if_safe - end - def archive_due_to_deceased! archive_reasons = teams.map do |team| diff --git a/app/views/patients/archive/new.html.erb b/app/views/patients/archive/new.html.erb new file mode 100644 index 0000000000..38e3168693 --- /dev/null +++ b/app/views/patients/archive/new.html.erb @@ -0,0 +1,36 @@ +<% content_for :before_main do %> + <%= render AppBacklinkComponent.new(patient_path(@patient), name: "patient") %> +<% end %> + +<% legend = "Why do you want to archive this record?" %> +<% content_for :page_title, legend %> + +<%= form_with model: @form, url: patient_archive_path(@patient) do |f| %> + <%= f.govuk_error_summary %> + + <%= f.govuk_radio_buttons_fieldset :type, + legend: { text: legend, size: "l", tag: "h1" }, + caption: { text: @patient.full_name } do %> + <%= f.govuk_radio_button :type, + :duplicate, + label: { text: "It’s a duplicate" }, link_errors: true do %> + + <%= f.govuk_text_field :nhs_number, + label: { text: "Enter the NHS number for the duplicate record" }, + hint: { text: "This will merge the duplicate records into a single record" } %> + <% end %> + + <%= f.govuk_radio_button :type, :imported_in_error, label: { text: "It was imported in error" } %> + + <%= f.govuk_radio_button :type, :moved_out_of_area, label: { text: "The child has moved out of the area" } %> + + <%= f.govuk_radio_button :type, :other, label: { text: "Other" } do %> + <%= f.govuk_text_field :other_details, label: { text: "Give details" } %> + <% end %> + <% end %> + +
+ <%= f.govuk_submit "Archive record", warning: true %> + <%= link_to "Return to child record", patient_path(@patient) %> +
+<% end %> diff --git a/app/views/patients/show.html.erb b/app/views/patients/show.html.erb index 0449212b15..fbfbf665fa 100644 --- a/app/views/patients/show.html.erb +++ b/app/views/patients/show.html.erb @@ -15,7 +15,12 @@ end %> <%= render AppPatientCardComponent.new(@patient, team: current_team) do %> - <%= govuk_button_link_to "Edit child record", edit_patient_path(@patient), secondary: true %> + <% if @patient.not_archived?(team: current_user.selected_team) %> +
+ <%= govuk_button_link_to "Edit child record", edit_patient_path(@patient), secondary: true %> + <%= govuk_button_link_to "Archive child record", new_patient_archive_path(@patient), class: "app-button--secondary-warning" %> + <% end %> +
<% end %> <%= render AppCardComponent.new(section: true) do |card| %> diff --git a/config/routes.rb b/config/routes.rb index b16146c3c5..b4530b66ad 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -141,6 +141,11 @@ resources :patients, only: %i[index show edit] do post "", action: :index, on: :collection + resource :archive, + path: "archive", + only: %i[new create], + controller: "patients/archive" + resources :parent_relationships, path: "parents", only: %i[edit update destroy] do diff --git a/spec/features/archive_children_spec.rb b/spec/features/archive_children_spec.rb index 9bd64c2f81..0e75c8384b 100644 --- a/spec/features/archive_children_spec.rb +++ b/spec/features/archive_children_spec.rb @@ -17,6 +17,96 @@ then_i_see_only_the_archived_patient end + scenario "Return to child record" do + given_an_unarchived_patient_exists + + when_i_visit_the_unarchived_patient + and_i_click_on_archive_record + then_i_see_the_archive_page + + when_i_click_back + then_i_see_the_unarchived_patient_page + + when_i_click_on_archive_record + then_i_see_the_archive_page + + when_i_click_return_to_child_record + then_i_see_the_unarchived_patient_page + end + + scenario "Mark as duplicate" do + given_an_unarchived_patient_exists + and_a_duplicate_patient_exists + + when_i_visit_the_unarchived_patient + and_i_click_on_archive_record + then_i_see_the_archive_page + + when_i_choose_the_duplicate_reason + and_i_fill_in_the_nhs_number + and_i_click_on_archive_record + then_i_see_the_duplicate_patient_page + and_i_see_a_success_message + + when_i_visit_the_children_page + then_i_see_only_the_duplicate_patient + end + + scenario "Mark as imported in error" do + given_an_unarchived_patient_exists + + when_i_visit_the_unarchived_patient + and_i_click_on_archive_record + then_i_see_the_archive_page + + when_i_choose_the_imported_in_error_reason + and_i_click_on_archive_record + then_i_see_the_unarchived_patient_page + and_i_see_a_success_message + and_i_see_an_activity_log_entry + + when_i_visit_the_children_page + and_i_filter_to_see_only_archived_patients + then_i_see_only_the_unarchived_patient + end + + scenario "Mark as moved out of the area" do + given_an_unarchived_patient_exists + + when_i_visit_the_unarchived_patient + and_i_click_on_archive_record + then_i_see_the_archive_page + + when_i_choose_the_moved_out_of_area_reason + and_i_click_on_archive_record + then_i_see_the_unarchived_patient_page + and_i_see_a_success_message + and_i_see_an_activity_log_entry + + when_i_visit_the_children_page + and_i_filter_to_see_only_archived_patients + then_i_see_only_the_unarchived_patient + end + + scenario "For other reason" do + given_an_unarchived_patient_exists + + when_i_visit_the_unarchived_patient + and_i_click_on_archive_record + then_i_see_the_archive_page + + when_i_choose_the_other_reason + and_i_fill_in_more_details + and_i_click_on_archive_record + then_i_see_the_unarchived_patient_page + and_i_see_a_success_message + and_i_see_an_activity_log_entry + + when_i_visit_the_children_page + and_i_filter_to_see_only_archived_patients + then_i_see_only_the_unarchived_patient + end + def given_an_team_exists programmes = [create(:programme, :flu)] @team = create(:team, programmes:) @@ -30,7 +120,7 @@ def and_i_am_signed_in end def given_an_unarchived_patient_exists - @unarchived_patient = create(:patient, session: @session) + @unarchived_patient = create(:patient, session: @session, nhs_number: nil) end def and_an_archived_patient_exists @@ -43,6 +133,10 @@ def and_an_archived_patient_exists ) end + def and_a_duplicate_patient_exists + @duplicate_patient = create(:patient, session: @session) + end + def when_i_visit_the_children_page visit patients_path end @@ -59,8 +153,87 @@ def when_i_filter_to_see_only_archived_patients click_on "Search" end + alias_method :and_i_filter_to_see_only_archived_patients, + :when_i_filter_to_see_only_archived_patients + def then_i_see_only_the_archived_patient expect(page).to have_content("1 child") expect(page).to have_content(@archived_patient.full_name) end + + def when_i_visit_the_unarchived_patient + visit patient_path(@unarchived_patient) + end + + def when_i_click_on_archive_record + click_on "Archive" + end + + alias_method :and_i_click_on_archive_record, :when_i_click_on_archive_record + + def then_i_see_the_archive_page + expect(page).to have_content("Why do you want to archive this record?") + end + + def when_i_click_back + click_on "Back" + end + + def when_i_click_return_to_child_record + click_on "Return to child record" + end + + def then_i_see_the_unarchived_patient_page + expect(page).to have_content("Child record") + expect(page).to have_content(@unarchived_patient.full_name) + end + + def when_i_choose_the_duplicate_reason + choose "It’s a duplicate" + end + + def and_i_fill_in_the_nhs_number + fill_in "Enter the NHS number for the duplicate record", + with: @duplicate_patient.nhs_number + end + + def then_i_see_the_duplicate_patient_page + expect(page).to have_content("Child record") + expect(page).to have_content(@duplicate_patient.full_name) + end + + def and_i_see_a_success_message + expect(page).to have_content("Child record archived") + end + + def and_i_see_an_activity_log_entry + click_on "Activity log" + expect(page).to have_content("Record archived:") + end + + def then_i_see_only_the_duplicate_patient + expect(page).to have_content("1 child") + expect(page).to have_content(@duplicate_patient.full_name) + end + + def when_i_choose_the_imported_in_error_reason + choose "It was imported in error" + end + + def then_i_see_only_the_unarchived_patient + expect(page).to have_content("1 child") + expect(page).to have_content(@unarchived_patient.full_name) + end + + def when_i_choose_the_moved_out_of_area_reason + choose "The child has moved out of the area" + end + + def when_i_choose_the_other_reason + choose "Other" + end + + def and_i_fill_in_more_details + fill_in "Give details", with: "A different reason." + end end From b35cc281ea7e813f5525ee4d52f1a17049fab6ba Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Sun, 3 Aug 2025 20:30:11 +0100 Subject: [PATCH 23/58] Archive and unarchive patients in school moves This updates the logic around school moves to make it such that when a patient moves out of an organisation they are archived with a suitable reason, and similarly when a patient moves in to an organisation they are unarchived. Jira-Issue: MAV-1506 --- app/models/school_move.rb | 21 ++++++++ spec/models/school_move_spec.rb | 90 +++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/app/models/school_move.rb b/app/models/school_move.rb index caf50df48f..7717b8cc28 100644 --- a/app/models/school_move.rb +++ b/app/models/school_move.rb @@ -52,6 +52,7 @@ class SchoolMove < ApplicationRecord def confirm!(user: nil) ActiveRecord::Base.transaction do update_patient! + update_archive_reasons!(user:) update_sessions! create_log_entry!(user:) SchoolMove.where(patient:).destroy_all if persisted? @@ -68,6 +69,26 @@ def update_patient! patient.update!(home_educated:, school:) end + def update_archive_reasons!(user:) + new_team_id = school&.team&.id || team_id + + patient.archive_reasons.where(team_id: new_team_id).destroy_all + + archive_reasons = + patient.teams.find_each.filter_map do |team| + next if team.id == new_team_id + + ArchiveReason.new( + patient_id:, + team_id: team.id, + type: "moved_out_of_area", + created_by: user + ) + end + + ArchiveReason.import!(archive_reasons, on_duplicate_key_ignore: true) + end + def update_sessions! patient .patient_sessions diff --git a/spec/models/school_move_spec.rb b/spec/models/school_move_spec.rb index 7a5a1628d3..dda0a87016 100644 --- a/spec/models/school_move_spec.rb +++ b/spec/models/school_move_spec.rb @@ -136,6 +136,24 @@ end end + shared_examples "unarchives the patient" do + it "unarchives the patient" do + expect(patient.archived?(team:)).to be(true) + confirm! + expect(patient.archived?(team:)).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) + confirm! + expect(patient.archived?(team:)).to be(true) + expect(patient.archived?(team: new_team)).to be(false) + end + end + shared_examples "destroys the school move" do it "destroys the school move and any others" do other_school_move = create(:school_move, :to_school, patient:) @@ -215,6 +233,72 @@ end end + context "with an archived patient" do + let(:patient) { create(:patient, team: nil) } + + before { create(:archive_reason, :imported_in_error, patient:, team:) } + + context "to a school with a scheduled session" do + let(:school_move) do + create(:school_move, :to_school, patient:, school:) + end + + let(:school) { create(:school, team:) } + let(:new_sessions) do + create_list( + :session, + 2, + date: session_date + 1.week, + location: school, + team:, + programmes: + ) + end + + include_examples "creates a log entry" + include_examples "sets the patient school" + include_examples "adds the patient to the new school sessions" + include_examples "unarchives the patient" + include_examples "destroys the school move" + end + + context "to a school with a completed session" do + let(:school_move) do + create(:school_move, :to_school, patient:, school:) + end + + let(:school) { create(:school, team:) } + let(:new_sessions) do + create_list( + :session, + 2, + date: session_date - 1.week, + location: school, + team:, + programmes: + ) + end + + include_examples "creates a log entry" + include_examples "sets the patient school" + include_examples "adds the patient to the new school sessions" + include_examples "unarchives the patient" + include_examples "destroys the school move" + end + + context "to home-schooled" do + let(:school_move) do + create(:school_move, :to_home_educated, team:, patient:) + end + + include_examples "creates a log entry" + include_examples "sets the patient to home-schooled" + include_examples "adds the patient to the community clinic" + include_examples "unarchives the patient" + include_examples "destroys the school move" + end + end + context "with a patient in a school session" do let(:session) do create(:session, date: session_date, team:, programmes:) @@ -304,6 +388,7 @@ include_examples "sets the patient school" include_examples "removes the patient from the old school sessions" include_examples "adds the patient to the new school sessions" + include_examples "archives the patient in the original team" include_examples "destroys the school move" end @@ -413,6 +498,7 @@ include_examples "creates a log entry" include_examples "sets the patient school" include_examples "keeps the patient in the old school sessions" + include_examples "archives the patient in the original team" include_examples "destroys the school move" end @@ -527,6 +613,7 @@ include_examples "creates a log entry" include_examples "sets the patient school" include_examples "removes the patient from the community clinic" + include_examples "archives the patient in the original team" include_examples "destroys the school move" end @@ -652,6 +739,7 @@ include_examples "creates a log entry" include_examples "sets the patient school" include_examples "keeps the patient in the community clinic" + include_examples "archives the patient in the original team" include_examples "destroys the school move" end @@ -768,6 +856,7 @@ include_examples "creates a log entry" include_examples "sets the patient school" include_examples "removes the patient from the community clinic" + include_examples "archives the patient in the original team" include_examples "destroys the school move" end @@ -884,6 +973,7 @@ include_examples "creates a log entry" include_examples "sets the patient school" include_examples "keeps the patient in the community clinic" + include_examples "archives the patient in the original team" include_examples "destroys the school move" end From 5c3e0bfb43eadbdfab4a78c2128b0c3546ceb160 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Sun, 3 Aug 2025 20:33:41 +0100 Subject: [PATCH 24/58] Add archive_deceased_patients task This adds a task which handles the migration of ensuring that patients who have a date of death are marked as archived. Jira-Issue: MAV-1532 --- lib/tasks/archive_deceased_patients.rake | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 lib/tasks/archive_deceased_patients.rake diff --git a/lib/tasks/archive_deceased_patients.rake b/lib/tasks/archive_deceased_patients.rake new file mode 100644 index 0000000000..5623ef920a --- /dev/null +++ b/lib/tasks/archive_deceased_patients.rake @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +desc "Migrate patients who are deceased to ensure they're archived." +task archive_deceased_patients: :environment do + Patient + .deceased + .includes(:teams) + .find_each do |patient| + # We're using a private method here in this temporary task since we will + # delete this task once it's been run in production. + patient.send(:archive_due_to_deceased!) + end +end From 68feb5b716f4afd0576fc9ab69523d5fd5b907b8 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Mon, 4 Aug 2025 10:23:39 +0100 Subject: [PATCH 25/58] Add archive_moved_out_of_cohort_patients task This adds a task which handles the migration of ensuring that patients who were previously removed from the cohort now appear as archived for that organisation. Jira-Issue: MAV-1515 --- .../archive_moved_out_of_cohort_patients.rake | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 lib/tasks/archive_moved_out_of_cohort_patients.rake diff --git a/lib/tasks/archive_moved_out_of_cohort_patients.rake b/lib/tasks/archive_moved_out_of_cohort_patients.rake new file mode 100644 index 0000000000..c9c5a804cf --- /dev/null +++ b/lib/tasks/archive_moved_out_of_cohort_patients.rake @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +desc "Migrate patients who were moved out of cohorts to ensure they're archived." +task archive_moved_out_of_cohort_patients: :environment do + Organisation.find_each do |team| + user = OpenStruct.new(selected_team: team) + + patients_in_cohort = team.patients + patients_associated_with_team = + PatientPolicy::Scope.new(user, Patient).resolve + + patients_not_in_cohort = patients_associated_with_team - patients_in_cohort + + archive_reasons = + patients_not_in_cohort.map do |patient| + ArchiveReason.new( + patient:, + team:, + type: "other", + other_details: "Unknown: before reasons added" + ) + end + + ArchiveReason.import!(archive_reasons, on_duplicate_key_ignore: true) + end +end From 6c0cca3d91cbf3080929006d5ad70c78de1e1d06 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 5 Aug 2025 18:10:33 +0100 Subject: [PATCH 26/58] Don't add deceased patients to sessions This ensures that when a patient has a date of death, they are not added to any upcoming sessions. Co-authored-by: Karim Elmestekawy Jira-Issue: MAV-1228 --- app/models/patient_import_row.rb | 2 ++ spec/models/class_import_row_spec.rb | 18 ++++++++++++++++++ spec/models/cohort_import_row_spec.rb | 6 ++++++ 3 files changed, 26 insertions(+) diff --git a/app/models/patient_import_row.rb b/app/models/patient_import_row.rb index ff099b0b33..b081f9cba9 100644 --- a/app/models/patient_import_row.rb +++ b/app/models/patient_import_row.rb @@ -39,6 +39,8 @@ def to_patient end def to_school_move(patient) + return if patient.deceased? + if patient.new_record? || patient.school != school || patient.home_educated != home_educated || patient.not_in_team? school_move = diff --git a/spec/models/class_import_row_spec.rb b/spec/models/class_import_row_spec.rb index e751f0024e..d1a1623e17 100644 --- a/spec/models/class_import_row_spec.rb +++ b/spec/models/class_import_row_spec.rb @@ -131,6 +131,24 @@ end end + describe "#to_school_move" do + subject { class_import_row.to_school_move(patient) } + + let(:data) { valid_data } + + let(:patient) { class_import_row.to_patient } + + context "without a date of death" do + it { should_not be_nil } + end + + context "with a date of death" do + before { patient.update!(date_of_death: today) } + + it { should be_nil } + end + end + describe "#to_parents" do subject(:parents) { class_import_row.to_parents } diff --git a/spec/models/cohort_import_row_spec.rb b/spec/models/cohort_import_row_spec.rb index c99fb8f8b6..9b3a9e04e5 100644 --- a/spec/models/cohort_import_row_spec.rb +++ b/spec/models/cohort_import_row_spec.rb @@ -561,6 +561,12 @@ it { should_not be_nil } + context "with a date of death" do + before { patient.update!(date_of_death: today) } + + it { should be_nil } + end + describe "#school" do subject(:school) { school_move.school } From 9c20da5bede9de24c6ab14c7d870cb9bde15f8d8 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 5 Aug 2025 17:31:14 +0100 Subject: [PATCH 27/58] Redirect to patient if no session exists If a patient is being matched to a consent form manually, and the patient is not eligible for any of the programmes we can't redirect the user to the patient in a session as it fails to generate a suitable URL. Instead we can redirect the user to the global child record. This is unlikely to happen in production, but we've been seeing it happen a few times in the test environments due to test data. Sentry-Issue: 6591726028 --- app/controllers/consent_forms_controller.rb | 22 +++++++++----- .../parental_consent_manual_matching_spec.rb | 30 +++++++++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/app/controllers/consent_forms_controller.rb b/app/controllers/consent_forms_controller.rb index 7c1e1da31b..9d779d9180 100644 --- a/app/controllers/consent_forms_controller.rb +++ b/app/controllers/consent_forms_controller.rb @@ -35,20 +35,28 @@ def edit_match def update_match @consent_form.match_with_patient!(@patient, current_user:) - session = @patient.pending_sessions.first || @consent_form.original_session + session = + @patient + .pending_sessions + .has_programmes(@consent_form.programmes) + .first || @consent_form.original_session patient_session = - PatientSession.includes_programmes.find_by!(patient: @patient, session:) + PatientSession.includes_programmes.find_by(patient: @patient, session:) + + programme = patient_session&.programmes&.first + + heading_link_href = + if programme.nil? + patient_path(@patient) + else + session_patient_programme_path(session, @patient, programme) + end flash[:success] = { heading: "Consent matched for", heading_link_text: @patient.full_name, heading_link_href: - session_patient_programme_path( - session, - @patient, - patient_session.programmes.first - ) } redirect_to action: :index diff --git a/spec/features/parental_consent_manual_matching_spec.rb b/spec/features/parental_consent_manual_matching_spec.rb index a4549ba662..947a32e857 100644 --- a/spec/features/parental_consent_manual_matching_spec.rb +++ b/spec/features/parental_consent_manual_matching_spec.rb @@ -24,6 +24,27 @@ then_i_see_the_consent_was_matched_manually end + scenario "Consent isn't matched automatically, nurse matches it manually, patient is not eligible for programme" do + given_the_patient_has_aged_out_of_the_programme + + when_i_go_to_the_dashboard + and_i_click_on_unmatched_consent_responses + then_i_am_on_the_unmatched_responses_page + and_i_see_one_response + + when_i_choose_a_consent_response + then_i_am_on_the_consent_matching_page + + when_i_search_for_the_child + and_i_select_the_child_record + then_i_can_review_the_match + + when_i_link_the_response_with_the_record + and_i_click_on_the_patient + then_the_parent_consent_is_shown + and_the_patient_is_not_in_a_session + end + scenario "Consent is marked as invalid" do when_i_go_to_the_dashboard and_i_click_on_unmatched_consent_responses @@ -56,6 +77,11 @@ def given_the_app_is_setup @patient = create(:patient, session: @session) end + def given_the_patient_has_aged_out_of_the_programme + @consent_form.update!(date_of_birth: @consent_form.date_of_birth - 10.years) + @patient.update!(birth_academic_year: @patient.birth_academic_year - 10) + end + def when_i_go_to_the_dashboard sign_in @user visit dashboard_path @@ -106,6 +132,10 @@ def then_the_parent_consent_is_shown expect(page).to have_content(@consent_form.parent_full_name) end + def and_the_patient_is_not_in_a_session + expect(page).not_to have_content("Session activity and notes") + end + def when_i_click_on_the_activity_log click_on "Session activity and notes" end From da33f46c5afa538f92d045d6f943300a057b0c97 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Mon, 4 Aug 2025 19:29:55 +0100 Subject: [PATCH 28/58] Show notify_parents_on_vaccination on confirmation If a child self-consents, we ask them if they would like their parents to receive confirmation of their vaccination. Until 4f87a86ece6fdc0155abb482ca49fbb6e7547232 we weren't able to show this as it wasn't being stored in the database, but we can now show this information to the users when they view a consent response. Jira-Issue: MAV-1708 --- .../app_consent_summary_component.rb | 18 +++++++++++++- app/views/draft_consents/confirm.html.erb | 1 + .../app_consent_summary_component_spec.rb | 24 +++++++++++++++++++ spec/features/self_consent_spec.rb | 2 ++ spec/features/verbal_consent_given_spec.rb | 3 +++ ...al_consent_refused_personal_choice_spec.rb | 6 +++-- 6 files changed, 51 insertions(+), 3 deletions(-) diff --git a/app/components/app_consent_summary_component.rb b/app/components/app_consent_summary_component.rb index f0fa705c29..40b4c33978 100644 --- a/app/components/app_consent_summary_component.rb +++ b/app/components/app_consent_summary_component.rb @@ -54,6 +54,22 @@ def call end end + unless consent.notify_parents_on_vaccination.nil? + summary_list.with_row do |row| + row.with_key { "Confirmation of vaccination sent to parent?" } + row.with_value do + consent.notify_parents_on_vaccination ? "Yes" : "No" + end + if (href = change_links[:notify_parents_on_vaccination]) + row.with_action( + text: "Change", + visually_hidden_text: "decision", + href: + ) + end + end + end + if consent.reason_for_refusal.present? summary_list.with_row do |row| row.with_key { "Reason for refusal" } @@ -63,7 +79,7 @@ def call unless consent.notify_parent_on_refusal.nil? summary_list.with_row do |row| - row.with_key { "Confirmation of decision sent to parent" } + row.with_key { "Confirmation of decision sent to parent?" } row.with_value { consent.notify_parent_on_refusal ? "Yes" : "No" } end end diff --git a/app/views/draft_consents/confirm.html.erb b/app/views/draft_consents/confirm.html.erb index d8360ff6d8..1d5bdc63d5 100644 --- a/app/views/draft_consents/confirm.html.erb +++ b/app/views/draft_consents/confirm.html.erb @@ -16,6 +16,7 @@ <%= render AppConsentSummaryComponent.new(@draft_consent, change_links: { response: wizard_path("agree"), route: wizard_path(@draft_consent.via_self_consent? ? "who" : "route"), + notify_parents_on_vaccination: wizard_path("notify-parents-on-vaccination"), }) %> <% end %> diff --git a/spec/components/app_consent_summary_component_spec.rb b/spec/components/app_consent_summary_component_spec.rb index 47ef334f05..f92fb36413 100644 --- a/spec/components/app_consent_summary_component_spec.rb +++ b/spec/components/app_consent_summary_component_spec.rb @@ -35,6 +35,30 @@ it { should have_content("Notes") } end + it { should_not have_content("Confirmation of vaccination sent to parent?") } + + context "when the child doesn't want the parents to know about the vaccination" do + let(:consent) { create(:consent, :given, :self_consent) } + + it do + expect(rendered).to have_content( + "Confirmation of vaccination sent to parent?\nNo" + ) + end + end + + context "when the child wants the parents to know about the vaccination" do + let(:consent) do + create(:consent, :given, :self_consent, :notify_parents_on_vaccination) + end + + it do + expect(rendered).to have_content( + "Confirmation of vaccination sent to parent?\nYes" + ) + end + end + it { should_not have_content("Consent also given for injected vaccine?") } context "with the flu programme" do diff --git a/spec/features/self_consent_spec.rb b/spec/features/self_consent_spec.rb index 9c2eefb668..383a328c0a 100644 --- a/spec/features/self_consent_spec.rb +++ b/spec/features/self_consent_spec.rb @@ -246,6 +246,8 @@ def and_the_child_can_give_their_own_consent_that_the_nurse_records choose "Child (Gillick competent)" 5.times { click_on "Continue" } + expect(page).to have_content("Confirmation of vaccination sent to parent") + click_on "Confirm" expect(page).to have_content("Consent recorded for #{@patient.full_name}") diff --git a/spec/features/verbal_consent_given_spec.rb b/spec/features/verbal_consent_given_spec.rb index 7743f6ee7c..c572b3895c 100644 --- a/spec/features/verbal_consent_given_spec.rb +++ b/spec/features/verbal_consent_given_spec.rb @@ -159,6 +159,9 @@ def record_that_verbal_consent_was_given( def then_i_see_the_check_and_confirm_page expect(page).to have_content("Check and confirm answers") expect(page).to have_content(["Method", "By phone"].join) + expect(page).not_to have_content( + "Confirmation of vaccination sent to parent" + ) end def and_i_see_the_flu_injection_consent_given diff --git a/spec/features/verbal_consent_refused_personal_choice_spec.rb b/spec/features/verbal_consent_refused_personal_choice_spec.rb index 1c28dbc4f2..c5d894f89d 100644 --- a/spec/features/verbal_consent_refused_personal_choice_spec.rb +++ b/spec/features/verbal_consent_refused_personal_choice_spec.rb @@ -65,9 +65,11 @@ def when_i_record_the_consent_refusal_and_reason(notify_parent:) expect(page).to have_content(["Name", @parent.full_name].join) if notify_parent - expect(page).to have_content("Confirmation of decision sent to parentYes") + expect(page).to have_content( + "Confirmation of decision sent to parent?Yes" + ) else - expect(page).to have_content("Confirmation of decision sent to parentNo") + expect(page).to have_content("Confirmation of decision sent to parent?No") end click_button "Confirm" From bfa93291aac02a6d701cdbffa895b603b73b9fa1 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 5 Aug 2025 19:59:14 +0100 Subject: [PATCH 29/58] Fix incorrect reference to organisations These have now been renamed to `Team` and so we need to do the same in this temporary Rake task. --- lib/tasks/archive_moved_out_of_cohort_patients.rake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/tasks/archive_moved_out_of_cohort_patients.rake b/lib/tasks/archive_moved_out_of_cohort_patients.rake index c9c5a804cf..7f09d02445 100644 --- a/lib/tasks/archive_moved_out_of_cohort_patients.rake +++ b/lib/tasks/archive_moved_out_of_cohort_patients.rake @@ -2,7 +2,7 @@ desc "Migrate patients who were moved out of cohorts to ensure they're archived." task archive_moved_out_of_cohort_patients: :environment do - Organisation.find_each do |team| + Team.find_each do |team| user = OpenStruct.new(selected_team: team) patients_in_cohort = team.patients From 300e070b0345fa9ad9745ae030ce179606fc5f17 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 5 Aug 2025 20:24:00 +0100 Subject: [PATCH 30/58] Fix patient show page layout This was accidentally rebased incorrectly and the tags ended up the wrong way around in the ERB leading to the patient page looking incorrect. This wasn't spotted by any tests because the content was here, just visually it looked all wrong. --- app/views/patients/show.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/patients/show.html.erb b/app/views/patients/show.html.erb index fbfbf665fa..2b59bcce5c 100644 --- a/app/views/patients/show.html.erb +++ b/app/views/patients/show.html.erb @@ -19,8 +19,8 @@
<%= govuk_button_link_to "Edit child record", edit_patient_path(@patient), secondary: true %> <%= govuk_button_link_to "Archive child record", new_patient_archive_path(@patient), class: "app-button--secondary-warning" %> - <% end %> -
+
+ <% end %> <% end %> <%= render AppCardComponent.new(section: true) do |card| %> From 9e433e0246e45ec5c3e2e756999259a46771b859 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 5 Aug 2025 12:39:43 +0100 Subject: [PATCH 31/58] Flatten Location::ProgrammeYearGroup model This moves the model out of the `Location` module as we're going to be using it in more contexts where it doesn't make sense to have it in the module. Although we could keep it in the module for the upcoming changes, this makes it simpler. Jira-Issue: MAV-1512 --- app/controllers/draft_imports_controller.rb | 2 +- .../programmes/overview_controller.rb | 2 +- .../programmes/patients_controller.rb | 2 +- .../schools/add_programme_year_group.rb | 6 ++++- .../schools/remove_programme_year_group.rb | 2 +- app/lib/status_updater.rb | 4 ++-- app/models/location.rb | 6 ++--- ...up.rb => location_programme_year_group.rb} | 2 +- app/models/patient_session.rb | 2 +- app/models/session.rb | 4 +--- app/models/team.rb | 4 +--- ...> location_programme_year_group_policy.rb} | 2 +- .../location_programme_year_groups.rb | 3 +-- ..._schools_add_programme_year_groups_spec.rb | 2 +- ...hools_remove_programme_year_groups_spec.rb | 7 ++++-- spec/features/cli_teams_add_programme_spec.rb | 2 +- ... => location_programme_year_group_spec.rb} | 2 +- spec/models/location_spec.rb | 22 ++++++++++--------- spec/models/onboarding_spec.rb | 10 ++++----- 19 files changed, 45 insertions(+), 41 deletions(-) rename app/models/{location/programme_year_group.rb => location_programme_year_group.rb} (94%) rename app/policies/{location/programme_year_group_policy.rb => location_programme_year_group_policy.rb} (78%) rename spec/models/{location/programme_year_group_spec.rb => location_programme_year_group_spec.rb} (95%) diff --git a/app/controllers/draft_imports_controller.rb b/app/controllers/draft_imports_controller.rb index 6bbbf57fc6..d4214dcfa3 100644 --- a/app/controllers/draft_imports_controller.rb +++ b/app/controllers/draft_imports_controller.rb @@ -44,7 +44,7 @@ def set_location_options def set_year_group_options year_groups = @location - .programme_year_groups + .location_programme_year_groups .where(programme: current_team.programmes) .pluck_year_groups diff --git a/app/controllers/programmes/overview_controller.rb b/app/controllers/programmes/overview_controller.rb index 748d2fb33a..7ac47be57b 100644 --- a/app/controllers/programmes/overview_controller.rb +++ b/app/controllers/programmes/overview_controller.rb @@ -25,7 +25,7 @@ def set_patients def set_patient_count_by_year_group year_groups = - policy_scope(Location::ProgrammeYearGroup).where( + policy_scope(LocationProgrammeYearGroup).where( programme: @programme ).pluck_year_groups diff --git a/app/controllers/programmes/patients_controller.rb b/app/controllers/programmes/patients_controller.rb index b22c52953f..7c67db1799 100644 --- a/app/controllers/programmes/patients_controller.rb +++ b/app/controllers/programmes/patients_controller.rb @@ -7,7 +7,7 @@ class Programmes::PatientsController < Programmes::BaseController def index @year_groups = - policy_scope(Location::ProgrammeYearGroup).where( + policy_scope(LocationProgrammeYearGroup).where( programme: @programme ).pluck_year_groups diff --git a/app/lib/mavis_cli/schools/add_programme_year_group.rb b/app/lib/mavis_cli/schools/add_programme_year_group.rb index 4741561c1f..7fe1925deb 100644 --- a/app/lib/mavis_cli/schools/add_programme_year_group.rb +++ b/app/lib/mavis_cli/schools/add_programme_year_group.rb @@ -33,7 +33,11 @@ def call(urn:, programme_type:, year_groups:, **) ActiveRecord::Base.transaction do year_groups.each do |year_group| - location.programme_year_groups.create!(programme:, year_group:) + LocationProgrammeYearGroup.create!( + location:, + programme:, + year_group: + ) end end end diff --git a/app/lib/mavis_cli/schools/remove_programme_year_group.rb b/app/lib/mavis_cli/schools/remove_programme_year_group.rb index 744e9c0baf..f083c67e79 100644 --- a/app/lib/mavis_cli/schools/remove_programme_year_group.rb +++ b/app/lib/mavis_cli/schools/remove_programme_year_group.rb @@ -34,7 +34,7 @@ def call(urn:, programme_type:, year_groups:, **) ActiveRecord::Base.transaction do year_groups.each do |year_group| location - .programme_year_groups + .location_programme_year_groups .find_by(programme:, year_group:) &.destroy! end diff --git a/app/lib/status_updater.rb b/app/lib/status_updater.rb index 0cf7f759cd..7f8ae66c44 100644 --- a/app/lib/status_updater.rb +++ b/app/lib/status_updater.rb @@ -200,7 +200,7 @@ def registration_statuses_to_import def programme_ids_per_year_group @programme_ids_per_year_group ||= - Location::ProgrammeYearGroup + LocationProgrammeYearGroup .distinct .pluck(:programme_id, :year_group) .each_with_object({}) do |(programme_id, year_group), hash| @@ -211,7 +211,7 @@ def programme_ids_per_year_group def programme_ids_per_location_id_and_year_group @programme_ids_per_location_id_and_year_group ||= - Location::ProgrammeYearGroup + LocationProgrammeYearGroup .distinct .pluck(:location_id, :programme_id, :year_group) .each_with_object({}) do |(location_id, programme_id, year_group), hash| diff --git a/app/models/location.rb b/app/models/location.rb index e482f6e198..86a6388101 100644 --- a/app/models/location.rb +++ b/app/models/location.rb @@ -45,13 +45,13 @@ class Location < ApplicationRecord has_many :consent_forms has_many :patients, foreign_key: :school_id - has_many :programme_year_groups + has_many :location_programme_year_groups has_many :sessions has_one :team, through: :subteam has_many :programmes, -> { distinct.order(:type) }, - through: :programme_year_groups + through: :location_programme_year_groups # This is based on the school statuses from the DfE GIAS data. enum :status, @@ -123,7 +123,7 @@ def create_default_programme_year_groups!(programmes) end end - Location::ProgrammeYearGroup.import!( + LocationProgrammeYearGroup.import!( %i[location_id programme_id year_group], rows, on_duplicate_key_ignore: true diff --git a/app/models/location/programme_year_group.rb b/app/models/location_programme_year_group.rb similarity index 94% rename from app/models/location/programme_year_group.rb rename to app/models/location_programme_year_group.rb index 2e51e6f6f7..845f2e282a 100644 --- a/app/models/location/programme_year_group.rb +++ b/app/models/location_programme_year_group.rb @@ -20,7 +20,7 @@ # fk_rails_... (location_id => locations.id) ON DELETE => cascade # fk_rails_... (programme_id => programmes.id) ON DELETE => cascade # -class Location::ProgrammeYearGroup < ApplicationRecord +class LocationProgrammeYearGroup < ApplicationRecord audited associated_with: :location belongs_to :location diff --git a/app/models/patient_session.rb b/app/models/patient_session.rb index d2dc1fad7d..fbb4eea540 100644 --- a/app/models/patient_session.rb +++ b/app/models/patient_session.rb @@ -98,7 +98,7 @@ class PatientSession < ApplicationRecord # Is the patient eligible for any of those programmes by year group? location_programme_year_groups = - Location::ProgrammeYearGroup + LocationProgrammeYearGroup .where("programme_id = session_programmes.programme_id") .where("location_id = sessions.location_id") .where( diff --git a/app/models/session.rb b/app/models/session.rb index 428d30f7cb..1ef7bb8a31 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -53,9 +53,7 @@ class Session < ApplicationRecord has_many :location_programme_year_groups, -> { where(programme: it.programmes) }, - through: :location, - source: :programme_year_groups, - class_name: "Location::ProgrammeYearGroup" + through: :location accepts_nested_attributes_for :session_dates, allow_destroy: true diff --git a/app/models/team.rb b/app/models/team.rb index c93366c74a..525f33fd8c 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -57,9 +57,7 @@ class Team < ApplicationRecord has_many :location_programme_year_groups, -> { where(programme: it.programmes) }, - through: :locations, - source: :programme_year_groups, - class_name: "Location::ProgrammeYearGroup" + through: :locations has_and_belongs_to_many :users diff --git a/app/policies/location/programme_year_group_policy.rb b/app/policies/location_programme_year_group_policy.rb similarity index 78% rename from app/policies/location/programme_year_group_policy.rb rename to app/policies/location_programme_year_group_policy.rb index 5eeaf7b80f..1d071c1e75 100644 --- a/app/policies/location/programme_year_group_policy.rb +++ b/app/policies/location_programme_year_group_policy.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Location::ProgrammeYearGroupPolicy < ApplicationPolicy +class LocationProgrammeYearGroupPolicy < ApplicationPolicy class Scope < ApplicationPolicy::Scope def resolve scope.joins(location: :subteam).where( diff --git a/spec/factories/location_programme_year_groups.rb b/spec/factories/location_programme_year_groups.rb index bf2f9c5f52..2059d9b9bb 100644 --- a/spec/factories/location_programme_year_groups.rb +++ b/spec/factories/location_programme_year_groups.rb @@ -21,8 +21,7 @@ # fk_rails_... (programme_id => programmes.id) ON DELETE => cascade # FactoryBot.define do - factory :location_programme_year_group, - class: "Location::ProgrammeYearGroup" do + factory :location_programme_year_group do location programme year_group { (0..13).to_a.sample } diff --git a/spec/features/cli_schools_add_programme_year_groups_spec.rb b/spec/features/cli_schools_add_programme_year_groups_spec.rb index aa96f1dfb9..52bc376258 100644 --- a/spec/features/cli_schools_add_programme_year_groups_spec.rb +++ b/spec/features/cli_schools_add_programme_year_groups_spec.rb @@ -64,7 +64,7 @@ def then_a_programme_not_found_error_message_is_displayed def then_the_year_groups_are_added_to_the_school year_groups = @school - .programme_year_groups + .location_programme_year_groups .where(programme: @programme) .pluck(:year_group) diff --git a/spec/features/cli_schools_remove_programme_year_groups_spec.rb b/spec/features/cli_schools_remove_programme_year_groups_spec.rb index a795b88cc4..6bdeed9aca 100644 --- a/spec/features/cli_schools_remove_programme_year_groups_spec.rb +++ b/spec/features/cli_schools_remove_programme_year_groups_spec.rb @@ -48,7 +48,10 @@ def and_the_programme_exists def and_existing_programme_year_groups_exist (0..11).to_a.each do |year_group| - @school.programme_year_groups.create(programme: @programme, year_group:) + @school.location_programme_year_groups.create( + programme: @programme, + year_group: + ) end end @@ -71,7 +74,7 @@ def then_a_programme_not_found_error_message_is_displayed def then_the_year_groups_are_removed_from_the_school year_groups = @school - .programme_year_groups + .location_programme_year_groups .where(programme: @programme) .pluck(:year_group) diff --git a/spec/features/cli_teams_add_programme_spec.rb b/spec/features/cli_teams_add_programme_spec.rb index af1594470b..ccdf623529 100644 --- a/spec/features/cli_teams_add_programme_spec.rb +++ b/spec/features/cli_teams_add_programme_spec.rb @@ -85,7 +85,7 @@ def then_the_programme_is_added_to_the_team expect(@team.programmes).to include(@programme) location_programme_year_groups = - @school.programme_year_groups.where(programme: @programme) + @school.location_programme_year_groups.where(programme: @programme) expect(location_programme_year_groups.count).to eq(5) end end diff --git a/spec/models/location/programme_year_group_spec.rb b/spec/models/location_programme_year_group_spec.rb similarity index 95% rename from spec/models/location/programme_year_group_spec.rb rename to spec/models/location_programme_year_group_spec.rb index e575e3f95a..a559efb1a1 100644 --- a/spec/models/location/programme_year_group_spec.rb +++ b/spec/models/location_programme_year_group_spec.rb @@ -20,7 +20,7 @@ # fk_rails_... (location_id => locations.id) ON DELETE => cascade # fk_rails_... (programme_id => programmes.id) ON DELETE => cascade # -describe Location::ProgrammeYearGroup do +describe LocationProgrammeYearGroup do subject { build(:location_programme_year_group) } describe "associations" do diff --git a/spec/models/location_spec.rb b/spec/models/location_spec.rb index d0c8c10776..8054c90022 100644 --- a/spec/models/location_spec.rb +++ b/spec/models/location_spec.rb @@ -37,9 +37,11 @@ subject(:location) { build(:location) } describe "associations" do + it { should have_many(:location_programme_year_groups) } + it do expect(location).to have_many(:programmes).through( - :programme_year_groups + :location_programme_year_groups ).order(:type) end end @@ -209,7 +211,7 @@ it "doesn't create any programme year groups" do expect { create_default_programme_year_groups! }.not_to change( - location.programme_year_groups, + location.location_programme_year_groups, :count ) end @@ -220,13 +222,13 @@ it "creates only suitable year groups" do expect { create_default_programme_year_groups! }.to change( - location.programme_year_groups, + location.location_programme_year_groups, :count ).by(4) - expect(location.programme_year_groups.pluck(:year_group).sort).to eq( - (0..3).to_a - ) + expect( + location.location_programme_year_groups.pluck(:year_group).sort + ).to eq((0..3).to_a) end end @@ -235,13 +237,13 @@ it "creates only suitable year groups" do expect { create_default_programme_year_groups! }.to change( - location.programme_year_groups, + location.location_programme_year_groups, :count ).by(12) - expect(location.programme_year_groups.pluck(:year_group).sort).to eq( - (0..11).to_a - ) + expect( + location.location_programme_year_groups.pluck(:year_group).sort + ).to eq((0..11).to_a) end end end diff --git a/spec/models/onboarding_spec.rb b/spec/models/onboarding_spec.rb index 17d8ce98da..38b3a8378e 100644 --- a/spec/models/onboarding_spec.rb +++ b/spec/models/onboarding_spec.rb @@ -36,7 +36,7 @@ expect(team.locations.generic_clinic.count).to eq(1) generic_clinic = team.locations.generic_clinic.first expect(generic_clinic.year_groups).to eq([8, 9, 10, 11]) - expect(generic_clinic.programme_year_groups.count).to eq(4) + expect(generic_clinic.location_programme_year_groups.count).to eq(4) subteam1 = team.subteams.includes(:schools).find_by!(name: "Subteam 1") expect(subteam1.email).to eq("subteam-1@trust.nhs.uk") @@ -52,10 +52,10 @@ expect(subteam1.schools).to contain_exactly(school1, school2) expect(subteam2.schools).to contain_exactly(school3, school4) - expect(school1.programme_year_groups.count).to eq(4) - expect(school2.programme_year_groups.count).to eq(4) - expect(school3.programme_year_groups.count).to eq(4) - expect(school4.programme_year_groups.count).to eq(4) + expect(school1.location_programme_year_groups.count).to eq(4) + expect(school2.location_programme_year_groups.count).to eq(4) + expect(school3.location_programme_year_groups.count).to eq(4) + expect(school4.location_programme_year_groups.count).to eq(4) clinic1 = subteam1.community_clinics.find_by!(ods_code: nil) expect(clinic1.name).to eq("10 Downing Street") From 437c0eeb1b155ad3841f3bb3f54977cbd72de839 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 5 Aug 2025 10:18:42 +0100 Subject: [PATCH 32/58] Add ProgrammeYearGroups This is a class that handles determining the year groups of a programme for each team. Most teams this will be the standard year groups, but for some other teams it might be different. Jira-Issue: MAV-1512 --- .../programmes/overview_controller.rb | 5 +- .../programmes/patients_controller.rb | 5 +- app/lib/programme_year_groups.rb | 30 +++++++ .../concerns/has_programme_year_groups.rb | 10 +++ app/models/location.rb | 3 +- app/models/session.rb | 6 +- app/models/team.rb | 3 + .../location_programme_year_group_policy.rb | 13 --- spec/lib/programme_year_groups_spec.rb | 84 +++++++++++++++++++ 9 files changed, 136 insertions(+), 23 deletions(-) create mode 100644 app/lib/programme_year_groups.rb create mode 100644 app/models/concerns/has_programme_year_groups.rb delete mode 100644 app/policies/location_programme_year_group_policy.rb create mode 100644 spec/lib/programme_year_groups_spec.rb diff --git a/app/controllers/programmes/overview_controller.rb b/app/controllers/programmes/overview_controller.rb index 7ac47be57b..3d2af67d1a 100644 --- a/app/controllers/programmes/overview_controller.rb +++ b/app/controllers/programmes/overview_controller.rb @@ -24,10 +24,7 @@ def set_patients end def set_patient_count_by_year_group - year_groups = - policy_scope(LocationProgrammeYearGroup).where( - programme: @programme - ).pluck_year_groups + year_groups = current_team.programme_year_groups[@programme] patient_count_by_birth_academic_year = patients.group(:birth_academic_year).count diff --git a/app/controllers/programmes/patients_controller.rb b/app/controllers/programmes/patients_controller.rb index 7c67db1799..5286f1e935 100644 --- a/app/controllers/programmes/patients_controller.rb +++ b/app/controllers/programmes/patients_controller.rb @@ -6,10 +6,7 @@ class Programmes::PatientsController < Programmes::BaseController before_action :set_patient_search_form def index - @year_groups = - policy_scope(LocationProgrammeYearGroup).where( - programme: @programme - ).pluck_year_groups + @year_groups = current_team.programme_year_groups[@programme] scope = patients.includes( diff --git a/app/lib/programme_year_groups.rb b/app/lib/programme_year_groups.rb new file mode 100644 index 0000000000..494fe85464 --- /dev/null +++ b/app/lib/programme_year_groups.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class ProgrammeYearGroups + def initialize(location_programme_year_groups) + @location_programme_year_groups = location_programme_year_groups + + @year_groups = {} + end + + def [](programme) + @year_groups[programme.id] ||= year_groups_for_programme(programme) + end + + private + + attr_reader :location_programme_year_groups + + def year_groups_for_programme(programme) + if location_programme_year_groups.is_a?(Array) || + location_programme_year_groups.loaded? + location_programme_year_groups + .select { it.programme_id == programme.id } + .map(&:year_group) + .sort + .uniq + else + location_programme_year_groups.where(programme:).pluck_year_groups + end + end +end diff --git a/app/models/concerns/has_programme_year_groups.rb b/app/models/concerns/has_programme_year_groups.rb new file mode 100644 index 0000000000..391a96c479 --- /dev/null +++ b/app/models/concerns/has_programme_year_groups.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module HasProgrammeYearGroups + extend ActiveSupport::Concern + + def programme_year_groups + @programme_year_groups ||= + ProgrammeYearGroups.new(location_programme_year_groups) + end +end diff --git a/app/models/location.rb b/app/models/location.rb index 86a6388101..0f39713eb5 100644 --- a/app/models/location.rb +++ b/app/models/location.rb @@ -34,6 +34,7 @@ # class Location < ApplicationRecord include AddressConcern + include HasProgrammeYearGroups include ODSCodeConcern self.inheritance_column = nil @@ -44,8 +45,8 @@ class Location < ApplicationRecord belongs_to :subteam, optional: true has_many :consent_forms - has_many :patients, foreign_key: :school_id has_many :location_programme_year_groups + has_many :patients, foreign_key: :school_id has_many :sessions has_one :team, through: :subteam diff --git a/app/models/session.rb b/app/models/session.rb index 1ef7bb8a31..eac7e722fb 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -26,6 +26,8 @@ # fk_rails_... (team_id => teams.id) # class Session < ApplicationRecord + include HasProgrammeYearGroups + audited associated_with: :location has_associated_audits @@ -188,7 +190,9 @@ def started? Date.current > dates.min end - def year_groups = location_programme_year_groups.pluck_year_groups + def year_groups + @year_groups ||= location_programme_year_groups.pluck_year_groups + end def vaccine_methods programmes.flat_map(&:vaccine_methods).uniq.sort diff --git a/app/models/team.rb b/app/models/team.rb index 525f33fd8c..8ea468d5ac 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -30,6 +30,9 @@ # fk_rails_... (organisation_id => organisations.id) # class Team < ApplicationRecord + include HasProgrammeYearGroups + include ODSCodeConcern + audited associated_with: :organisation has_associated_audits diff --git a/app/policies/location_programme_year_group_policy.rb b/app/policies/location_programme_year_group_policy.rb deleted file mode 100644 index 1d071c1e75..0000000000 --- a/app/policies/location_programme_year_group_policy.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -class LocationProgrammeYearGroupPolicy < ApplicationPolicy - class Scope < ApplicationPolicy::Scope - def resolve - scope.joins(location: :subteam).where( - subteams: { - team: user.selected_team - } - ) - end - end -end diff --git a/spec/lib/programme_year_groups_spec.rb b/spec/lib/programme_year_groups_spec.rb new file mode 100644 index 0000000000..0c452a14d8 --- /dev/null +++ b/spec/lib/programme_year_groups_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +describe ProgrammeYearGroups do + shared_examples "all examples" do + subject(:year_groups) { programme_year_groups[programme] } + + let(:programme) { create(:programme, :hpv) } + + context "for a programme not administered" do + let(:team) { create(:team) } + + it { should be_empty } + end + + context "for a programme administered but no schools" do + let(:team) { create(:team, programmes: [programme]) } + + it { should be_empty } + end + + context "for a programme administered with schools" do + let(:team) { create(:team, programmes: [programme]) } + + before { create(:school, team:) } + + it { should eq([8, 9, 10, 11]) } + end + + context "when the school administers the programme for an extra year" do + let(:team) { create(:team, programmes: [programme]) } + + before do + location = create(:school, team:) + create( + :location_programme_year_group, + location:, + programme:, + year_group: 12 + ) + end + + it { should eq([8, 9, 10, 11, 12]) } + end + + context "for a different programme" do + let(:team) { create(:team, programmes: [create(:programme, :flu)]) } + + before { create(:school, :primary, team:) } + + it { should be_empty } + end + end + + describe "#[]" do + context "with a scope" do + let(:programme_year_groups) do + described_class.new(team.location_programme_year_groups) + end + + include_examples "all examples" + end + + context "with a loaded scope" do + let(:programme_year_groups) do + described_class.new( + Team + .includes(:location_programme_year_groups) + .find(team.id) + .location_programme_year_groups + ) + end + + include_examples "all examples" + end + + context "with an array" do + let(:programme_year_groups) do + described_class.new(team.location_programme_year_groups.to_a) + end + + include_examples "all examples" + end + end +end From 42ba0fa5df55fbf834c79c096554abf6765e5969 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 5 Aug 2025 13:42:15 +0100 Subject: [PATCH 33/58] Hide school and year group for some patients If a patient has aged out of all the programmes administered by the SAIS team, we should no longer show their school or year group. This is necessary because some schools will keep teaching patients beyond the normal vaccination programme year groups (for example year 12s and 13s). We would still want these patients to show in the list of children, but we need to make sure their year group and school are no longer visible. Jira-Issue: MAV-1512 --- app/components/app_child_summary_component.rb | 26 ++++++++------ app/components/app_patient_card_component.rb | 19 ++++++++--- .../programmes/patients_controller.rb | 3 +- app/models/patient.rb | 12 +++++-- app/models/user.rb | 4 ++- app/views/consent_forms/patient.html.erb | 2 +- app/views/patients/edit.html.erb | 2 +- .../patients/edit/nhs_number_merge.html.erb | 2 +- app/views/patients/show.html.erb | 2 +- app/views/programmes/patients/index.html.erb | 2 +- app/views/vaccination_records/show.html.erb | 2 +- .../app_child_summary_component_spec.rb | 2 +- .../app_patient_card_component_spec.rb | 16 +++++++-- spec/models/patient_spec.rb | 34 ++++++++++++++++++- 14 files changed, 100 insertions(+), 28 deletions(-) diff --git a/app/components/app_child_summary_component.rb b/app/components/app_child_summary_component.rb index ed390a4c60..880c5903f7 100644 --- a/app/components/app_child_summary_component.rb +++ b/app/components/app_child_summary_component.rb @@ -3,16 +3,18 @@ class AppChildSummaryComponent < ViewComponent::Base def initialize( child, - team: nil, + current_team: nil, show_parents: false, + show_school_and_year_group: true, change_links: {}, remove_links: {} ) super @child = child - @team = team + @current_team = current_team @show_parents = show_parents + @show_school_and_year_group = show_school_and_year_group @change_links = change_links @remove_links = remove_links end @@ -72,14 +74,16 @@ def call row.with_value { format_address } end end - summary_list.with_row do |row| - row.with_key { "School" } - row.with_value { format_school } - end - if @child.respond_to?(:year_group) + if @show_school_and_year_group summary_list.with_row do |row| - row.with_key { "Year group" } - row.with_value { format_year_group } + row.with_key { "School" } + row.with_value { format_school } + end + if @child.respond_to?(:year_group) + summary_list.with_row do |row| + row.with_key { "Year group" } + row.with_value { format_year_group } + end end end if (gp_practice = @child.try(:gp_practice)) @@ -129,7 +133,9 @@ def academic_year = AcademicYear.current def archive_reason @archive_reason ||= - (ArchiveReason.find_by(team: @team, patient: @child) if @team) + if @current_team + ArchiveReason.find_by(team: @current_team, patient: @child) + end end def format_nhs_number diff --git a/app/components/app_patient_card_component.rb b/app/components/app_patient_card_component.rb index bbd7d1d6be..9ee3e243c7 100644 --- a/app/components/app_patient_card_component.rb +++ b/app/components/app_patient_card_component.rb @@ -24,7 +24,12 @@ class AppPatientCardComponent < ViewComponent::Base <% end %> <%= render AppChildSummaryComponent.new( - patient, team:, show_parents: true, change_links:, remove_links: + patient, + current_team:, + show_parents: true, + show_school_and_year_group:, + change_links:, + remove_links: ) %> <%= content %> @@ -33,7 +38,7 @@ class AppPatientCardComponent < ViewComponent::Base def initialize( patient, - team: nil, + current_team:, change_links: {}, remove_links: {}, heading_level: 3 @@ -41,7 +46,7 @@ def initialize( super @patient = patient - @team = team + @current_team = current_team @change_links = change_links @remove_links = remove_links @heading_level = heading_level @@ -49,5 +54,11 @@ def initialize( private - attr_reader :patient, :team, :change_links, :remove_links, :heading_level + attr_reader :patient, + :current_team, + :change_links, + :remove_links, + :heading_level + + def show_school_and_year_group = patient.show_year_group?(team: current_team) end diff --git a/app/controllers/programmes/patients_controller.rb b/app/controllers/programmes/patients_controller.rb index 5286f1e935..829df9ea9b 100644 --- a/app/controllers/programmes/patients_controller.rb +++ b/app/controllers/programmes/patients_controller.rb @@ -12,7 +12,8 @@ def index patients.includes( :consent_statuses, :triage_statuses, - :vaccination_statuses + :vaccination_statuses, + school: :location_programme_year_groups ) @form.academic_year = @academic_year diff --git a/app/models/patient.rb b/app/models/patient.rb index e7b6e12ef1..2cb9beb1cf 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -326,8 +326,16 @@ def year_group(academic_year: nil) birth_academic_year.to_year_group(academic_year:) end - def year_group_changed? - birth_academic_year_changed? + def year_group_changed? = birth_academic_year_changed? + + def show_year_group?(team:, academic_year: nil) + year_group = self.year_group(academic_year:) + programme_year_groups = + school&.programme_year_groups || team.programme_year_groups + + team.programmes.any? do |programme| + programme_year_groups[programme].include?(year_group) + end end def consent_status(programme:, academic_year:) diff --git a/app/models/user.rb b/app/models/user.rb index 3349001fbe..31364ff300 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -109,7 +109,9 @@ def selected_organisation def selected_team # TODO: Select the right team based on the user's workgroup. @selected_team ||= - Team.includes(:programmes).find_by(organisation: selected_organisation) + Team.includes(:location_programme_year_groups, :programmes).find_by( + organisation: selected_organisation + ) end def requires_email_and_password? diff --git a/app/views/consent_forms/patient.html.erb b/app/views/consent_forms/patient.html.erb index b2d30414f5..8e9bbe14e3 100644 --- a/app/views/consent_forms/patient.html.erb +++ b/app/views/consent_forms/patient.html.erb @@ -10,7 +10,7 @@ <%= page_title %> <% end %> -<%= render AppPatientCardComponent.new(@patient) %> +<%= render AppPatientCardComponent.new(@patient, current_team:) %> <%= render AppConsentFormCardComponent.new(@consent_form) %> diff --git a/app/views/patients/edit.html.erb b/app/views/patients/edit.html.erb index fd42c39444..2e026efb6f 100644 --- a/app/views/patients/edit.html.erb +++ b/app/views/patients/edit.html.erb @@ -17,6 +17,6 @@ end, } %> -<%= render AppPatientCardComponent.new(@patient, change_links:, remove_links:, heading_level: 2) %> +<%= render AppPatientCardComponent.new(@patient, current_team:, change_links:, remove_links:, heading_level: 2) %> <%= govuk_button_link_to "Continue", patient_path(@patient) %> diff --git a/app/views/patients/edit/nhs_number_merge.html.erb b/app/views/patients/edit/nhs_number_merge.html.erb index 7e2d1f654b..4270d3e98f 100644 --- a/app/views/patients/edit/nhs_number_merge.html.erb +++ b/app/views/patients/edit/nhs_number_merge.html.erb @@ -13,7 +13,7 @@ Updating the NHS number for <%= @patient.full_name %> will merge their record with an existing record:

-<%= render AppPatientCardComponent.new(@existing_patient) %> +<%= render AppPatientCardComponent.new(@existing_patient, current_team:) %> <%= form_with model: @form, url: edit_nhs_number_merge_patient_path(@patient), method: :put do |f| %> <%= f.hidden_field :nhs_number %> diff --git a/app/views/patients/show.html.erb b/app/views/patients/show.html.erb index 2b59bcce5c..e4140bce22 100644 --- a/app/views/patients/show.html.erb +++ b/app/views/patients/show.html.erb @@ -14,7 +14,7 @@ nav.with_item(href: log_patient_path(@patient), text: "Activity log") end %> -<%= render AppPatientCardComponent.new(@patient, team: current_team) do %> +<%= render AppPatientCardComponent.new(@patient, current_team:) do %> <% if @patient.not_archived?(team: current_user.selected_team) %>
<%= govuk_button_link_to "Edit child record", edit_patient_path(@patient), secondary: true %> diff --git a/app/views/programmes/patients/index.html.erb b/app/views/programmes/patients/index.html.erb index d9a30d2671..e17366d1c2 100644 --- a/app/views/programmes/patients/index.html.erb +++ b/app/views/programmes/patients/index.html.erb @@ -37,7 +37,7 @@ programme: @programme, academic_year: @academic_year, triage_status: @form.triage_status, - show_year_group: true, + show_year_group: patient.show_year_group?(team: current_team), ) %> <% end %> <% end %> diff --git a/app/views/vaccination_records/show.html.erb b/app/views/vaccination_records/show.html.erb index 72dce345fd..98d11b887e 100644 --- a/app/views/vaccination_records/show.html.erb +++ b/app/views/vaccination_records/show.html.erb @@ -4,7 +4,7 @@ <%= h1 @patient.full_name %> -<%= render AppPatientCardComponent.new(@patient) %> +<%= render AppPatientCardComponent.new(@patient, current_team:) %> <%= render AppCardComponent.new do |c| %> <% c.with_heading { "Vaccination details" } %> diff --git a/spec/components/app_child_summary_component_spec.rb b/spec/components/app_child_summary_component_spec.rb index bc76cb98ad..0714955b8a 100644 --- a/spec/components/app_child_summary_component_spec.rb +++ b/spec/components/app_child_summary_component_spec.rb @@ -107,7 +107,7 @@ end context "when archived" do - let(:component) { described_class.new(patient, team:) } + let(:component) { described_class.new(patient, current_team: team) } let(:team) { create(:team) } diff --git a/spec/components/app_patient_card_component_spec.rb b/spec/components/app_patient_card_component_spec.rb index d4d2eace0c..a66259f953 100644 --- a/spec/components/app_patient_card_component_spec.rb +++ b/spec/components/app_patient_card_component_spec.rb @@ -5,16 +5,22 @@ let(:component) do described_class.new( - Patient.includes(parent_relationships: :parent).find(patient.id) + Patient.includes(parent_relationships: :parent).find(patient.id), + current_team: team ) end - let(:patient) { create(:patient) } + let(:programmes) { [create(:programme, :hpv)] } + let(:team) { create(:team, programmes:) } + let(:school) { create(:school, team:) } + + let(:patient) { create(:patient, school:, year_group: 8) } it { should have_content("Child") } it { should have_content("Full name") } it { should have_content("Date of birth") } + it { should have_content("Year group") } it { should have_content("Address") } context "with a deceased patient" do @@ -52,4 +58,10 @@ it { should have_content("Jenny Smith") } it { should have_content("Mum") } end + + context "when patient is too old for any programmes" do + let(:patient) { create(:patient, year_group: 13) } + + it { should_not have_content("Year group") } + end end diff --git a/spec/models/patient_spec.rb b/spec/models/patient_spec.rb index 92110e070d..c7745f5257 100644 --- a/spec/models/patient_spec.rb +++ b/spec/models/patient_spec.rb @@ -512,8 +512,40 @@ end end + describe "#show_year_group?" do + subject { patient.show_year_group?(team:) } + + let(:programmes) { [create(:programme, :flu), create(:programme, :hpv)] } + let(:team) { create(:team, programmes:) } + let(:school) { create(:school, team:) } + + context "for a year 1" do + let(:patient) { create(:patient, school:, year_group: 1) } + + it { should be(true) } + end + + context "for a year 7" do + let(:patient) { create(:patient, school:, year_group: 7) } + + it { should be(true) } + end + + context "for a year 11" do + let(:patient) { create(:patient, school:, year_group: 11) } + + it { should be(true) } + end + + context "for a year 12" do + let(:patient) { create(:patient, school:, year_group: 12) } + + it { should be(false) } + end + end + describe "#initials" do - subject(:initials) { patient.initials } + subject { patient.initials } let(:patient) { create(:patient, given_name: "John", family_name: "Doe") } From 8721ddb9bae1645e489721be24e6d3f9a2fb4aa3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Aug 2025 22:01:41 +0000 Subject: [PATCH 34/58] build(deps-dev): bump aws-sdk-s3 from 1.196.0 to 1.196.1 Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.196.0 to 1.196.1. - [Release notes](https://github.com/aws/aws-sdk-ruby/releases) - [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-ruby/commits) --- updated-dependencies: - dependency-name: aws-sdk-s3 dependency-version: 1.196.1 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index e3980c61ec..4b385e60f7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -113,7 +113,7 @@ GEM ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1141.0) + aws-partitions (1.1142.0) aws-sdk-accessanalyzer (1.76.0) aws-sdk-core (~> 3, >= 3.228.0) aws-sigv4 (~> 1.5) @@ -140,7 +140,7 @@ GEM aws-sdk-rds (1.286.0) aws-sdk-core (~> 3, >= 3.228.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.196.0) + aws-sdk-s3 (1.196.1) aws-sdk-core (~> 3, >= 3.228.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) From eb02b372862ae9b27cf88a140767c8ba624c3738 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Aug 2025 22:01:53 +0000 Subject: [PATCH 35/58] build(deps-dev): bump annotaterb from 4.17.0 to 4.18.0 Bumps [annotaterb](https://github.com/drwl/annotaterb) from 4.17.0 to 4.18.0. - [Changelog](https://github.com/drwl/annotaterb/blob/main/CHANGELOG.md) - [Commits](https://github.com/drwl/annotaterb/compare/v4.17.0...v4.18.0) --- updated-dependencies: - dependency-name: annotaterb dependency-version: 4.18.0 dependency-type: direct:development 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 e3980c61ec..44cdc3b353 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -101,7 +101,7 @@ GEM public_suffix (>= 2.0.2, < 7.0) aes_key_wrap (1.1.0) amazing_print (1.8.1) - annotaterb (4.17.0) + annotaterb (4.18.0) activerecord (>= 6.0.0) activesupport (>= 6.0.0) array_enum (1.6.0) From b043d55c8563ecc33974be67f0df76bb4805f06b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Aug 2025 22:02:05 +0000 Subject: [PATCH 36/58] build(deps): bump thruster from 0.1.14 to 0.1.15 Bumps [thruster](https://github.com/basecamp/thruster) from 0.1.14 to 0.1.15. - [Changelog](https://github.com/basecamp/thruster/blob/main/CHANGELOG.md) - [Commits](https://github.com/basecamp/thruster/compare/v0.1.14...v0.1.15) --- updated-dependencies: - dependency-name: thruster dependency-version: 0.1.15 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index e3980c61ec..62789df5fd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -664,8 +664,8 @@ GEM syntax_tree (>= 2.0.1) temple (0.10.0) thor (1.4.0) - thruster (0.1.14-arm64-darwin) - thruster (0.1.14-x86_64-linux) + thruster (0.1.15-arm64-darwin) + thruster (0.1.15-x86_64-linux) tilt (2.6.0) timeout (0.4.3) turbo-rails (2.0.16) From 2856308c5e4867dd46298bc4e38ec4fa1cafcac2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Aug 2025 22:02:32 +0000 Subject: [PATCH 37/58] build(deps-dev): bump aws-sdk-rds from 1.286.0 to 1.287.0 Bumps [aws-sdk-rds](https://github.com/aws/aws-sdk-ruby) from 1.286.0 to 1.287.0. - [Release notes](https://github.com/aws/aws-sdk-ruby/releases) - [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-rds/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-ruby/commits) --- updated-dependencies: - dependency-name: aws-sdk-rds dependency-version: 1.287.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 e3980c61ec..7e461e70df 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -113,7 +113,7 @@ GEM ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1141.0) + aws-partitions (1.1142.0) aws-sdk-accessanalyzer (1.76.0) aws-sdk-core (~> 3, >= 3.228.0) aws-sigv4 (~> 1.5) @@ -137,7 +137,7 @@ GEM aws-sdk-kms (1.110.0) aws-sdk-core (~> 3, >= 3.228.0) aws-sigv4 (~> 1.5) - aws-sdk-rds (1.286.0) + aws-sdk-rds (1.287.0) aws-sdk-core (~> 3, >= 3.228.0) aws-sigv4 (~> 1.5) aws-sdk-s3 (1.196.0) From 09b6747e3b1b03277794a4ee77000d3280348b25 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 6 Aug 2025 11:02:49 +0100 Subject: [PATCH 38/58] Hide archived patients by default When viewing a list of patients, we should be hiding archived patients by default, unless you're viewing a session in which case we would still want to see patients who are now archived but were previously in a session. Jira-Issue: MAV-1506 --- app/forms/patient_search_form.rb | 8 +++++++- spec/features/archive_children_spec.rb | 12 +++--------- spec/forms/patient_search_form_spec.rb | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/forms/patient_search_form.rb b/app/forms/patient_search_form.rb index 495dece32f..63fb17bf12 100644 --- a/app/forms/patient_search_form.rb +++ b/app/forms/patient_search_form.rb @@ -83,7 +83,13 @@ def filter_year_groups(scope) end def filter_archived(scope) - archived ? scope.archived(team:) : scope + if archived + scope.archived(team:) + elsif @session + scope + else + scope.not_archived(team:) + end end def filter_date_of_birth_year(scope) diff --git a/spec/features/archive_children_spec.rb b/spec/features/archive_children_spec.rb index 0e75c8384b..3835cf341c 100644 --- a/spec/features/archive_children_spec.rb +++ b/spec/features/archive_children_spec.rb @@ -11,7 +11,7 @@ and_an_archived_patient_exists when_i_visit_the_children_page - then_i_see_both_patients + then_i_see_only_the_unarchived_patient when_i_filter_to_see_only_archived_patients then_i_see_only_the_archived_patient @@ -141,10 +141,9 @@ def when_i_visit_the_children_page visit patients_path end - def then_i_see_both_patients - expect(page).to have_content("2 children") + def then_i_see_only_the_unarchived_patient + expect(page).to have_content("1 child") expect(page).to have_content(@unarchived_patient.full_name) - expect(page).to have_content(@archived_patient.full_name) end def when_i_filter_to_see_only_archived_patients @@ -220,11 +219,6 @@ def when_i_choose_the_imported_in_error_reason choose "It was imported in error" end - def then_i_see_only_the_unarchived_patient - expect(page).to have_content("1 child") - expect(page).to have_content(@unarchived_patient.full_name) - end - def when_i_choose_the_moved_out_of_area_reason choose "The child has moved out of the area" end diff --git a/spec/forms/patient_search_form_spec.rb b/spec/forms/patient_search_form_spec.rb index 9ad6eaf9be..d7132b4d83 100644 --- a/spec/forms/patient_search_form_spec.rb +++ b/spec/forms/patient_search_form_spec.rb @@ -88,8 +88,8 @@ expect(form.apply(scope)).to include(unarchived_patient) end - it "includes the archived patient" do - expect(form.apply(scope)).to include(archived_patient) + it "doesn't include the archived patient" do + expect(form.apply(scope)).not_to include(archived_patient) end end From e43e5f3044797ab37e7f8127e221a192439eec6f Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 6 Aug 2025 11:21:14 +0100 Subject: [PATCH 39/58] Show archived tag on patient page This adds a tag to be shown on the patient page when a patient is archived to match the latest designs in the prototype. Jira-Issue: MAV-1506 --- app/views/patients/_header.html.erb | 11 +++++++++++ app/views/patients/log.html.erb | 4 +--- app/views/patients/show.html.erb | 4 +--- spec/features/archive_children_spec.rb | 6 ++++++ 4 files changed, 19 insertions(+), 6 deletions(-) create mode 100644 app/views/patients/_header.html.erb diff --git a/app/views/patients/_header.html.erb b/app/views/patients/_header.html.erb new file mode 100644 index 0000000000..f50b6612f4 --- /dev/null +++ b/app/views/patients/_header.html.erb @@ -0,0 +1,11 @@ +<%= h1 page_title: @patient.initials do %> + <%= @patient.full_name %> +<% end %> + +
    +
  • + <% if @patient.archived?(team: current_team) %> + <%= govuk_tag(text: "Archived", colour: "grey") %> + <% end %> +
  • +
diff --git a/app/views/patients/log.html.erb b/app/views/patients/log.html.erb index 39f7627c9c..5d2f5d88f2 100644 --- a/app/views/patients/log.html.erb +++ b/app/views/patients/log.html.erb @@ -5,9 +5,7 @@ ]) %> <% end %> -<%= h1 page_title: @patient.initials do %> - <%= @patient.full_name %> -<% end %> +<%= render "patients/header" %> <%= render AppSecondaryNavigationComponent.new do |nav| nav.with_item(href: patient_path(@patient), text: "Child record") diff --git a/app/views/patients/show.html.erb b/app/views/patients/show.html.erb index e4140bce22..cbe0023316 100644 --- a/app/views/patients/show.html.erb +++ b/app/views/patients/show.html.erb @@ -5,9 +5,7 @@ ]) %> <% end %> -<%= h1 page_title: @patient.initials do %> - <%= @patient.full_name %> -<% end %> +<%= render "patients/header" %> <%= render AppSecondaryNavigationComponent.new do |nav| nav.with_item(href: patient_path(@patient), text: "Child record", selected: true) diff --git a/spec/features/archive_children_spec.rb b/spec/features/archive_children_spec.rb index 3835cf341c..4f1e000109 100644 --- a/spec/features/archive_children_spec.rb +++ b/spec/features/archive_children_spec.rb @@ -63,6 +63,7 @@ and_i_click_on_archive_record then_i_see_the_unarchived_patient_page and_i_see_a_success_message + and_i_see_an_archived_tag and_i_see_an_activity_log_entry when_i_visit_the_children_page @@ -81,6 +82,7 @@ and_i_click_on_archive_record then_i_see_the_unarchived_patient_page and_i_see_a_success_message + and_i_see_an_archived_tag and_i_see_an_activity_log_entry when_i_visit_the_children_page @@ -205,6 +207,10 @@ def and_i_see_a_success_message expect(page).to have_content("Child record archived") end + def and_i_see_an_archived_tag + expect(page).to have_content("Archived") + end + def and_i_see_an_activity_log_entry click_on "Activity log" expect(page).to have_content("Record archived:") From b9e3c638b04c89c0f3eb2c9425b8e3378a4769a3 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 6 Aug 2025 11:25:20 +0100 Subject: [PATCH 40/58] Update archive flash message This updates the content to match the latest designs. Jira-Issue: MAV-1506 --- app/controllers/patients/archive_controller.rb | 2 +- spec/features/archive_children_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/patients/archive_controller.rb b/app/controllers/patients/archive_controller.rb index af3d1550fb..176de06508 100644 --- a/app/controllers/patients/archive_controller.rb +++ b/app/controllers/patients/archive_controller.rb @@ -16,7 +16,7 @@ def create ) if @form.save - flash[:success] = "Child record archived" + flash[:success] = "This record has been archived" redirect_to patient_path( @form.duplicate? ? @form.existing_patient : @patient ) diff --git a/spec/features/archive_children_spec.rb b/spec/features/archive_children_spec.rb index 4f1e000109..eb6f0c07fe 100644 --- a/spec/features/archive_children_spec.rb +++ b/spec/features/archive_children_spec.rb @@ -204,7 +204,7 @@ def then_i_see_the_duplicate_patient_page end def and_i_see_a_success_message - expect(page).to have_content("Child record archived") + expect(page).to have_content("This record has been archived") end def and_i_see_an_archived_tag From 65abb2699cc12346aaffeb972c7a0c887d8fda6c Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 6 Aug 2025 12:34:13 +0100 Subject: [PATCH 41/58] Handle discarded vaccination records on merge When merging two patients we need to make sure that any discarded vaccination records are also merged correctly, without this the user sees an unhandled error. Jira-Issue: MAV-1718 --- app/lib/patient_merger.rb | 5 +++-- spec/lib/patient_merger_spec.rb | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/app/lib/patient_merger.rb b/app/lib/patient_merger.rb index b899a1ac7a..c13cdea490 100644 --- a/app/lib/patient_merger.rb +++ b/app/lib/patient_merger.rb @@ -53,8 +53,9 @@ def call ) patient_to_destroy.triages.update_all(patient_id: patient_to_keep.id) - vaccination_record_ids = patient_to_destroy.vaccination_records.ids - patient_to_destroy.vaccination_records.update_all( + vaccination_record_ids = + patient_to_destroy.vaccination_records.with_discarded.ids + patient_to_destroy.vaccination_records.with_discarded.update_all( patient_id: patient_to_keep.id ) diff --git a/spec/lib/patient_merger_spec.rb b/spec/lib/patient_merger_spec.rb index 8f0ce907fe..713f07b3d8 100644 --- a/spec/lib/patient_merger_spec.rb +++ b/spec/lib/patient_merger_spec.rb @@ -80,6 +80,15 @@ programme: ) end + let(:discarded_vaccination_record) do + create( + :vaccination_record, + :discarded, + patient: patient_to_destroy, + session:, + programme: + ) + end it "destroys one of the patients" do expect { call }.to change(Patient, :count).by(-1) @@ -167,6 +176,12 @@ ) end + it "moves discarded vaccination records" do + expect { call }.to change { + discarded_vaccination_record.reload.patient + }.to(patient_to_keep) + end + it "enqueues sync jobs for vaccination records" do Flipper.enable(:enqueue_sync_vaccination_records_to_nhs) expect { call }.to have_enqueued_job(SyncVaccinationRecordToNHSJob).with( From cd8fa05664c5757064d4a9cb6d71615ff6504a08 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Wed, 6 Aug 2025 12:43:59 +0100 Subject: [PATCH 42/58] Log requests to immunisations API Jira-Issue: MAV-1719 --- app/jobs/sync_vaccination_record_to_nhs_job.rb | 7 ++++++- app/lib/nhs/immunisations_api.rb | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/app/jobs/sync_vaccination_record_to_nhs_job.rb b/app/jobs/sync_vaccination_record_to_nhs_job.rb index 3fe7a3e7eb..e7eb92c0b6 100644 --- a/app/jobs/sync_vaccination_record_to_nhs_job.rb +++ b/app/jobs/sync_vaccination_record_to_nhs_job.rb @@ -6,6 +6,11 @@ class SyncVaccinationRecordToNHSJob < ApplicationJob retry_on Faraday::ServerError, wait: :polynomially_longer def perform(vaccination_record) - NHS::ImmunisationsAPI.sync_immunisation(vaccination_record) + tx_id = SecureRandom.urlsafe_base64(16) + SemanticLogger.tagged(tx_id:, job_id: provider_job_id || job_id) do + Sentry.set_tags(tx_id:, job_id: provider_job_id || job_id) + + NHS::ImmunisationsAPI.sync_immunisation(vaccination_record) + end end end diff --git a/app/lib/nhs/immunisations_api.rb b/app/lib/nhs/immunisations_api.rb index 949a3bbe57..70b35f2fdf 100644 --- a/app/lib/nhs/immunisations_api.rb +++ b/app/lib/nhs/immunisations_api.rb @@ -30,6 +30,11 @@ def create_immunisation(vaccination_record) check_vaccination_record_for_create_or_update(vaccination_record) + Rails.logger.info( + "Recording vaccination record to immunisations API:" \ + " #{vaccination_record.id}" + ) + response = NHS::API.connection.post( "/immunisation-fhir-api/FHIR/R4/Immunization", @@ -79,6 +84,11 @@ def update_immunisation(vaccination_record) raise "Vaccination record #{vaccination_record.id} missing ETag" end + Rails.logger.info( + "Updating vaccination record in immunisations API:" \ + " #{vaccination_record.id}" + ) + nhs_id = vaccination_record.nhs_immunisations_api_id response = NHS::API.connection.put( @@ -133,6 +143,11 @@ def delete_immunisation(vaccination_record) raise "Vaccination record #{vaccination_record.id} missing NHS Immunisation ID" end + Rails.logger.info( + "Deleting vaccination record from immunisations API:" \ + " #{vaccination_record.id}" + ) + nhs_id = vaccination_record.nhs_immunisations_api_id response = NHS::API.connection.delete( From d4efd5c1fe6213c921eb375a6098a27eb681d0e7 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 6 Aug 2025 14:20:58 +0100 Subject: [PATCH 43/58] Show year group according to pending academic year In https://github.com/nhsuk/manage-vaccinations-in-schools/pull/4175 we made it so that the year group is hidden for some patients. This was supposed to apply for the pending academic year, not the current one. The idea is that during the preparation period, you would be looking at patients who are due to age out at the start of next academic year, and for those patients the year group is hidden. Jira-Issue: MAV-1512 --- app/models/patient.rb | 3 +- spec/models/patient_spec.rb | 60 ++++++++++++++++++++++++++++--------- 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/app/models/patient.rb b/app/models/patient.rb index 2cb9beb1cf..ffb5e1a9b1 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -328,7 +328,8 @@ def year_group(academic_year: nil) def year_group_changed? = birth_academic_year_changed? - def show_year_group?(team:, academic_year: nil) + def show_year_group?(team:) + academic_year = AcademicYear.pending year_group = self.year_group(academic_year:) programme_year_groups = school&.programme_year_groups || team.programme_year_groups diff --git a/spec/models/patient_spec.rb b/spec/models/patient_spec.rb index c7745f5257..825c9538d8 100644 --- a/spec/models/patient_spec.rb +++ b/spec/models/patient_spec.rb @@ -519,28 +519,60 @@ let(:team) { create(:team, programmes:) } let(:school) { create(:school, team:) } - context "for a year 1" do - let(:patient) { create(:patient, school:, year_group: 1) } + context "outside the preparation period" do + around { |example| travel_to(Date.new(2025, 7, 31)) { example.run } } - it { should be(true) } - end + context "for a year 1" do + let(:patient) { create(:patient, school:, year_group: 1) } + + it { should be(true) } + end - context "for a year 7" do - let(:patient) { create(:patient, school:, year_group: 7) } + context "for a year 7" do + let(:patient) { create(:patient, school:, year_group: 7) } - it { should be(true) } - end + it { should be(true) } + end - context "for a year 11" do - let(:patient) { create(:patient, school:, year_group: 11) } + context "for a year 11" do + let(:patient) { create(:patient, school:, year_group: 11) } - it { should be(true) } + it { should be(true) } + end + + context "for a year 12" do + let(:patient) { create(:patient, school:, year_group: 12) } + + it { should be(false) } + end end - context "for a year 12" do - let(:patient) { create(:patient, school:, year_group: 12) } + context "inside the preparation period" do + around { |example| travel_to(Date.new(2025, 8, 1)) { example.run } } - it { should be(false) } + context "for a year 1" do + let(:patient) { create(:patient, school:, year_group: 1) } + + it { should be(true) } + end + + context "for a year 7" do + let(:patient) { create(:patient, school:, year_group: 7) } + + it { should be(true) } + end + + context "for a year 11" do + let(:patient) { create(:patient, school:, year_group: 11) } + + it { should be(false) } + end + + context "for a year 12" do + let(:patient) { create(:patient, school:, year_group: 12) } + + it { should be(false) } + end end end From ac2f61a48f0f509f454e10ee73e95c567f92ba73 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 6 Aug 2025 15:07:38 +0100 Subject: [PATCH 44/58] Ensure patients are unarchived When an archived patient is re-imported, we should unarchive them. This was added in to the school moves in b35cc281ea7e813f5525ee4d52f1a17049fab6ba however, we didn't handle the case where a school move wouldn't be created. If a patient is archived, and they're being re-imported exactly as they were (same school, etc) we don't create a school move. This results in them not being unarchived. Instead, we can ensure that a school move is created if the patient is already archived. I haven't written a feature test for this scenario as it's being covered by the regression tests. Jira-Issue: MAV-1506 --- app/models/patient_import.rb | 3 ++- app/models/patient_import_row.rb | 3 ++- spec/models/class_import_row_spec.rb | 31 +++++++++++++++++++++++ spec/models/cohort_import_row_spec.rb | 36 +++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/app/models/patient_import.rb b/app/models/patient_import.rb index dfb08753e3..dca92833dd 100644 --- a/app/models/patient_import.rb +++ b/app/models/patient_import.rb @@ -32,7 +32,8 @@ def process_row(row) @school_moves_to_save ||= Set.new if (school_move = row.to_school_move(patient)) - if (patient.school.nil? && !patient.home_educated) || patient.not_in_team? + if (patient.school.nil? && !patient.home_educated) || + patient.not_in_team? || patient.archived?(team:) @school_moves_to_confirm.add(school_move) else @school_moves_to_save.add(school_move) diff --git a/app/models/patient_import_row.rb b/app/models/patient_import_row.rb index b081f9cba9..4a8c6f26ed 100644 --- a/app/models/patient_import_row.rb +++ b/app/models/patient_import_row.rb @@ -42,7 +42,8 @@ def to_school_move(patient) return if patient.deceased? if patient.new_record? || patient.school != school || - patient.home_educated != home_educated || patient.not_in_team? + patient.home_educated != home_educated || patient.not_in_team? || + patient.archived?(team:) school_move = if school SchoolMove.find_or_initialize_by(patient:, school:) diff --git a/spec/models/class_import_row_spec.rb b/spec/models/class_import_row_spec.rb index d1a1623e17..a2cda8ce1d 100644 --- a/spec/models/class_import_row_spec.rb +++ b/spec/models/class_import_row_spec.rb @@ -147,6 +147,37 @@ it { should be_nil } end + + context "with an existing patient that was previously archived" do + subject(:school_move) do + class_import_row.to_school_move(existing_patient) + end + + let(:existing_patient) do + create( + :patient, + address_postcode: "SW1A 1AA", + family_name: "Smith", + gender_code: "male", + given_name: "Jimmy", + nhs_number: "9990000018", + session: + ) + end + + let(:data) { valid_data } + + before do + create( + :archive_reason, + :moved_out_of_area, + team:, + patient: existing_patient + ) + end + + it { should_not be_nil } + end end describe "#to_parents" do diff --git a/spec/models/cohort_import_row_spec.rb b/spec/models/cohort_import_row_spec.rb index 9b3a9e04e5..3a6c367c82 100644 --- a/spec/models/cohort_import_row_spec.rb +++ b/spec/models/cohort_import_row_spec.rb @@ -633,6 +633,42 @@ it { should_not be_nil } end + + context "with an existing patient that was previously archived" do + subject(:school_move) do + cohort_import_row.to_school_move(existing_patient) + end + + let(:data) { valid_data } + + let(:location) { Location.school.find_by!(urn: "123456") } + let(:session) do + create(:session, location:, team:, programmes: [programme]) + end + + let(:existing_patient) do + create( + :patient, + address_postcode: "SW1A 1AA", + family_name: "Smith", + gender_code: "male", + given_name: "Jimmy", + nhs_number: "9990000018", + session: + ) + end + + before do + create( + :archive_reason, + :moved_out_of_area, + team:, + patient: existing_patient + ) + end + + it { should_not be_nil } + end end describe "#to_parent_relationships" do From 794e4058336df23b9b896706627d6e82ced4fc0c Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 6 Aug 2025 17:00:54 +0100 Subject: [PATCH 45/58] Fix archive_moved_out_of_cohort_patients This fixes an issue with the Rake task that was introduced as part of the introduction of the new `Organisation` model. --- .../archive_moved_out_of_cohort_patients.rake | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/lib/tasks/archive_moved_out_of_cohort_patients.rake b/lib/tasks/archive_moved_out_of_cohort_patients.rake index 7f09d02445..094f968539 100644 --- a/lib/tasks/archive_moved_out_of_cohort_patients.rake +++ b/lib/tasks/archive_moved_out_of_cohort_patients.rake @@ -2,25 +2,32 @@ desc "Migrate patients who were moved out of cohorts to ensure they're archived." task archive_moved_out_of_cohort_patients: :environment do - Team.find_each do |team| - user = OpenStruct.new(selected_team: team) + Team + .includes(:organisation) + .find_each do |team| + user = + OpenStruct.new( + selected_team: team, + selected_organisation: team.organisation + ) - patients_in_cohort = team.patients - patients_associated_with_team = - PatientPolicy::Scope.new(user, Patient).resolve + patients_in_cohort = team.patients + patients_associated_with_team = + PatientPolicy::Scope.new(user, Patient).resolve - patients_not_in_cohort = patients_associated_with_team - patients_in_cohort + patients_not_in_cohort = + patients_associated_with_team - patients_in_cohort - archive_reasons = - patients_not_in_cohort.map do |patient| - ArchiveReason.new( - patient:, - team:, - type: "other", - other_details: "Unknown: before reasons added" - ) - end + archive_reasons = + patients_not_in_cohort.map do |patient| + ArchiveReason.new( + patient:, + team:, + type: "other", + other_details: "Unknown: before reasons added" + ) + end - ArchiveReason.import!(archive_reasons, on_duplicate_key_ignore: true) - end + ArchiveReason.import!(archive_reasons, on_duplicate_key_ignore: true) + end end From f43b19ff7b29c2d8ce6b229fe2a0582908ab3cad Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Wed, 30 Jul 2025 16:10:04 +0100 Subject: [PATCH 46/58] Fix important notice ordering bug Previously the important notices weren't being ordered by the correct date variable --- app/components/app_notices_table_component.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/components/app_notices_table_component.rb b/app/components/app_notices_table_component.rb index ffe5c3b5d0..fb10330d16 100644 --- a/app/components/app_notices_table_component.rb +++ b/app/components/app_notices_table_component.rb @@ -21,9 +21,10 @@ def render? private def notices - (deceased_notices + invalidated_notices + restricted_notices) - .sort_by { _1[:date] } - .reverse + ( + deceased_notices + invalidated_notices + restricted_notices + + gillick_no_notify_notices + ).sort_by { _1[:date_time] }.reverse end def deceased_notices From 147265217fa796867dfbf61c8a9313993e729c6f Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Wed, 30 Jul 2025 16:09:47 +0100 Subject: [PATCH 47/58] Add important notice for `gillick_no_notify` patients --- app/components/app_notices_table_component.rb | 33 +++++++++++++++++-- app/controllers/imports/notices_controller.rb | 1 + app/models/patient.rb | 12 ++++++- app/views/imports/notices/index.html.erb | 9 +++-- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/app/components/app_notices_table_component.rb b/app/components/app_notices_table_component.rb index fb10330d16..5406bc6980 100644 --- a/app/components/app_notices_table_component.rb +++ b/app/components/app_notices_table_component.rb @@ -4,18 +4,20 @@ class AppNoticesTableComponent < ViewComponent::Base def initialize( deceased_patients:, invalidated_patients:, - restricted_patients: + restricted_patients:, + gillick_no_notify_patients: ) super @deceased_patients = deceased_patients @invalidated_patients = invalidated_patients @restricted_patients = restricted_patients + @gillick_no_notify_patients = gillick_no_notify_patients end def render? @deceased_patients.present? || @invalidated_patients.present? || - @restricted_patients.present? + @restricted_patients.present? || @gillick_no_notify_patients.present? end private @@ -56,4 +58,31 @@ def restricted_notices } end end + + def gillick_no_notify_notices + + @gillick_no_notify_patients.map do |patient| + vaccination_records = patient.vaccination_records.includes(:programme).select { it.notify_parents == false } + + { + patient:, + date_time: + patient + .vaccination_records + .reject(&:notify_parents?) + .max_by(&:created_at) + &.created_at || Time.current, + message: + "Child gave consent for #{format_vaccinations(vaccination_records)} under Gillick competence and " \ + "does not want their parents to be notified. " \ + "These records will not be automatically synced with GP records. " \ + "Your team must let the child’s GP know they were vaccinated." + } + end + end + + def format_vaccinations(vaccination_records) + "#{vaccination_records.map(&:programme).map(&:name).to_sentence} " \ + "#{"vaccination".pluralize(vaccination_records.length)}" + end end diff --git a/app/controllers/imports/notices_controller.rb b/app/controllers/imports/notices_controller.rb index af80f13315..12e9602997 100644 --- a/app/controllers/imports/notices_controller.rb +++ b/app/controllers/imports/notices_controller.rb @@ -9,5 +9,6 @@ def index @deceased_patients = policy_scope(Patient).deceased @invalidated_patients = policy_scope(Patient).invalidated @restricted_patients = policy_scope(Patient).restricted + @gillick_no_notify_patients = policy_scope(Patient).gillick_no_notify end end diff --git a/app/models/patient.rb b/app/models/patient.rb index ffb5e1a9b1..ac6ec89a79 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -122,7 +122,17 @@ class Patient < ApplicationRecord scope :not_deceased, -> { where(date_of_death: nil) } scope :restricted, -> { where.not(restricted_at: nil) } - scope :with_notice, -> { deceased.or(restricted).or(invalidated) } + scope :gillick_no_notify, + -> do + joins(:vaccination_records).where( + vaccination_records: { + notify_parents: false + } + ).distinct + end + + scope :with_notice, + -> { (deceased + restricted + invalidated + gillick_no_notify).uniq } scope :appear_in_programmes, ->(programmes, academic_year:) do diff --git a/app/views/imports/notices/index.html.erb b/app/views/imports/notices/index.html.erb index c6ac6eeb9a..2c5c8689af 100644 --- a/app/views/imports/notices/index.html.erb +++ b/app/views/imports/notices/index.html.erb @@ -4,8 +4,13 @@ <%= render AppImportsNavigationComponent.new(active: :notices) %> -<% if @deceased_patients.any? || @invalidated_patients.any? || @restricted_patients.any? %> - <%= render AppNoticesTableComponent.new(deceased_patients: @deceased_patients, invalidated_patients: @invalidated_patients, restricted_patients: @restricted_patients) %> +<% if @deceased_patients.any? || @invalidated_patients.any? || @restricted_patients.any? || @gillick_no_notify_patients.any? %> + <%= render AppNoticesTableComponent.new( + deceased_patients: @deceased_patients, + invalidated_patients: @invalidated_patients, + restricted_patients: @restricted_patients, + gillick_no_notify_patients: @gillick_no_notify_patients, + ) %> <% else %>

<%= t(".no_results") %>

<% end %> From 32b87712024edf7b31c408ff01032b073345314b Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 6 Aug 2025 14:06:43 +0100 Subject: [PATCH 48/58] Add not_appear_in_programmes scope This acts as the inverse to `appear_in_programmes` and it allows us to filter on patients who are in an organisation, but don't appear in any sessions for any programmes. Jira-Issue: MAV-1513 --- app/models/patient.rb | 13 +++++ spec/models/patient_spec.rb | 102 ++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/app/models/patient.rb b/app/models/patient.rb index ffb5e1a9b1..20cfaf6743 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -137,6 +137,19 @@ class Patient < ApplicationRecord ) end + scope :not_appear_in_programmes, + ->(programmes, academic_year:) do + where.not( + PatientSession + .joins(:session) + .where(sessions: { academic_year: }) + .where("patient_id = patients.id") + .appear_in_programmes(programmes) + .arel + .exists + ) + end + scope :with_pending_changes, -> { where.not(pending_changes: {}) } scope :search_by_name, diff --git a/spec/models/patient_spec.rb b/spec/models/patient_spec.rb index 825c9538d8..1ebda50eef 100644 --- a/spec/models/patient_spec.rb +++ b/spec/models/patient_spec.rb @@ -201,6 +201,108 @@ end end + describe "#not_appear_in_programmes" do + subject(:scope) do + described_class.not_appear_in_programmes(programmes, academic_year:) + end + + let(:programmes) { create_list(:programme, 1, :td_ipv) } + let(:academic_year) { AcademicYear.current } + + it { should be_empty } + + context "with a patient in no sessions" do + let(:patient) { create(:patient) } + + it { should include(patient) } + end + + context "in a session with the right year group" do + let(:session) { create(:session, programmes:) } + + before { create(:patient, session:, year_group: 9) } + + it { should be_empty } + end + + context "in a session but the wrong year group" do + let(:session) { create(:session, programmes:) } + + let(:patient) { create(:patient, session:, year_group: 8) } + + it { should include(patient) } + end + + context "in a session with the right year group for the programme but not the location" do + let(:location) { create(:school, :secondary) } + let(:session) { create(:session, location:, programmes:) } + let(:patient) { create(:patient, session:, year_group: 9) } + + before do + programmes.each do |programme| + create( + :location_programme_year_group, + programme:, + location:, + year_group: 10 + ) + end + end + + it { should include(patient) } + end + + context "in multiple sessions with the right year group for one programme" do + let(:flu_programme) { create(:programme, :flu) } + let(:hpv_programme) { create(:programme, :hpv) } + + let(:location) do + create(:school, programmes: [flu_programme, hpv_programme]) + end + + # Year 4 is eligible for flu only. + let!(:patient) { create(:patient, year_group: 4) } + + # Year 9 is eligible for flu and HPV only. + let(:another_patient) { create(:patient, year_group: 9) } + + let(:flu_session) do + create(:session, location:, programmes: [flu_programme]) + end + let(:hpv_session) do + create(:session, location:, programmes: [hpv_programme]) + end + + before do + create(:patient_session, patient:, session: flu_session) + create(:patient_session, patient:, session: hpv_session) + + create( + :patient_session, + patient: another_patient, + session: flu_session + ) + create( + :patient_session, + patient: another_patient, + session: hpv_session + ) + end + + context "for the right programme" do + let(:programmes) { [flu_programme] } + + it { should be_empty } + end + + context "for the wrong programme" do + let(:programmes) { [hpv_programme] } + + it { should include(patient) } + end + end + end + describe "#search_by_name" do subject(:scope) { described_class.search_by_name(query) } From ea7e1e1e7c609d8196995993eaf173f185cbd4a9 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 6 Aug 2025 15:40:09 +0100 Subject: [PATCH 49/58] Hide patients aged out of all programmes When viewing a list of programmes, by default we should hide any patients who have aged out of the programmes. Instead, an option will be provided to allow viewing patients who have aged out of the programmes. Jira-Issue: MAV-1513 --- app/forms/patient_search_form.rb | 11 +++ spec/features/edit_vaccination_record_spec.rb | 2 +- spec/forms/patient_search_form_spec.rb | 95 +++++++++++++------ 3 files changed, 76 insertions(+), 32 deletions(-) diff --git a/app/forms/patient_search_form.rb b/app/forms/patient_search_form.rb index 63fb17bf12..a957a7dfa0 100644 --- a/app/forms/patient_search_form.rb +++ b/app/forms/patient_search_form.rb @@ -49,6 +49,7 @@ def programmes def apply(scope) scope = filter_name(scope) scope = filter_year_groups(scope) + scope = filter_aged_out_of_programmes(scope) scope = filter_archived(scope) scope = filter_date_of_birth_year(scope) scope = filter_nhs_number(scope) @@ -82,6 +83,16 @@ def filter_year_groups(scope) end end + def filter_aged_out_of_programmes(scope) + if @session || archived + scope + else + # Archived patients won't appear in programmes, so we need to + # skip this check if we're trying to view archived patients. + scope.appear_in_programmes(team.programmes, academic_year:) + end + end + def filter_archived(scope) if archived scope.archived(team:) diff --git a/spec/features/edit_vaccination_record_spec.rb b/spec/features/edit_vaccination_record_spec.rb index f7e452ed0d..912b67df32 100644 --- a/spec/features/edit_vaccination_record_spec.rb +++ b/spec/features/edit_vaccination_record_spec.rb @@ -233,7 +233,7 @@ def and_an_hpv_programme_is_underway @replacement_batch = create(:batch, :not_expired, team: @team, vaccine: @vaccine) - location = create(:school) + location = create(:school, team: @team) @session = create( diff --git a/spec/forms/patient_search_form_spec.rb b/spec/forms/patient_search_form_spec.rb index d7132b4d83..5281fa0224 100644 --- a/spec/forms/patient_search_form_spec.rb +++ b/spec/forms/patient_search_form_spec.rb @@ -16,7 +16,8 @@ let(:request_path) { "/patients" } let(:session) { nil } - let(:team) { create(:team) } + let(:programmes) { [create(:programme, :flu)] } + let(:team) { create(:team, programmes:) } let(:archived) { nil } let(:consent_statuses) { nil } @@ -57,6 +58,8 @@ context "for patients" do let(:scope) { Patient.all } + let(:session_for_patients) { create(:session, team:, programmes:) } + it "doesn't raise an error" do expect { form.apply(scope) }.not_to raise_error end @@ -74,7 +77,9 @@ let(:triage_status) { nil } let(:year_groups) { nil } - let!(:unarchived_patient) { create(:patient) } + let!(:unarchived_patient) do + create(:patient, session: session_for_patients) + end let!(:archived_patient) { create(:patient) } before do @@ -100,7 +105,7 @@ expect(form.apply(scope)).not_to include(unarchived_patient) end - it "includes the unarchived patient" do + it "includes the archived patient" do expect(form.apply(scope)).to include(archived_patient) end end @@ -119,7 +124,13 @@ let(:triage_status) { nil } let(:year_groups) { nil } - let(:patient) { create(:patient, date_of_birth: Date.new(2000, 1, 1)) } + let(:patient) do + create( + :patient, + session: session_for_patients, + date_of_birth: Date.new(2000, 1, 1) + ) + end context "with only a year specified" do let(:date_of_birth_year) { 2000 } @@ -163,7 +174,7 @@ let(:date_of_birth_year) { nil } let(:missing_nhs_number) { nil } let(:programme_status) { nil } - let(:programme_types) { [programme.type] } + let(:programme_types) { programmes.map(&:type) } let(:q) { nil } let(:register_status) { nil } let(:session_status) { nil } @@ -171,14 +182,10 @@ let(:year_groups) { nil } let(:programme) { create(:programme, :menacwy) } + let(:programmes) { [programme] } context "with a patient eligible for the programme" do - let(:patient) do - create(:patient, programmes: [programme]).tap do |patient| - session = create(:session, programmes: [programme]) - create(:patient_session, patient:, session:) - end - end + let(:patient) { create(:patient, session: session_for_patients) } it "is included" do expect(form.apply(scope)).to include(patient) @@ -201,19 +208,21 @@ let(:date_of_birth_year) { nil } let(:missing_nhs_number) { nil } let(:programme_status) { "vaccinated" } - let(:programme_types) { [programme.type] } + let(:programme_types) { programmes.map(&:type) } let(:q) { nil } let(:register_status) { nil } let(:session_status) { nil } let(:triage_status) { nil } let(:year_groups) { nil } - let(:programme) { create(:programme) } - it "filters on programme status" do - patient = create(:patient, :vaccinated, programmes: [programme]) - session = create(:session, programmes: [programme]) - create(:patient_session, patient:, session:) + patient = + create( + :patient, + :vaccinated, + programmes:, + session: session_for_patients + ) expect(form.apply(scope)).to include(patient) end @@ -233,19 +242,44 @@ let(:year_groups) { nil } let(:patient_a) do - create(:patient, given_name: "Harry", family_name: "Potter") + create( + :patient, + given_name: "Harry", + family_name: "Potter", + session: session_for_patients + ) end let(:patient_b) do - create(:patient, given_name: "Hari", family_name: "Potter") + create( + :patient, + given_name: "Hari", + family_name: "Potter", + session: session_for_patients + ) end let(:patient_c) do - create(:patient, given_name: "Arry", family_name: "Pott") + create( + :patient, + given_name: "Arry", + family_name: "Pott", + session: session_for_patients + ) end let(:patient_d) do - create(:patient, given_name: "Ron", family_name: "Weasley") + create( + :patient, + given_name: "Ron", + family_name: "Weasley", + session: session_for_patients + ) end let(:patient_e) do - create(:patient, given_name: "Ginny", family_name: "Weasley") + create( + :patient, + given_name: "Ginny", + family_name: "Weasley", + session: session_for_patients + ) end context "with no search query" do @@ -271,8 +305,7 @@ context "for patient sessions" do let(:scope) { PatientSession.all } - let(:session) { create(:session, programmes: [programme]) } - let(:programme) { create(:programme) } + let(:session) { create(:session, programmes:) } it "doesn't raise an error" do expect { form.apply(scope) }.not_to raise_error @@ -285,14 +318,14 @@ let(:date_of_birth_year) { nil } let(:missing_nhs_number) { nil } let(:programme_status) { nil } - let(:programme_types) { [programme.type] } + let(:programme_types) { programmes.map(&:type) } let(:q) { nil } let(:register_status) { nil } let(:session_status) { nil } let(:triage_status) { nil } let(:year_groups) { nil } - let(:programme) { create(:programme, :menacwy) } + let(:programmes) { [create(:programme, :menacwy)] } context "with a patient session eligible for the programme" do let(:patient) { create(:patient, year_group: 9) } @@ -322,7 +355,7 @@ let(:date_of_birth_year) { nil } let(:missing_nhs_number) { nil } let(:programme_status) { nil } - let(:programme_types) { [programme.type] } + let(:programme_types) { programmes.map(&:type) } let(:q) { nil } let(:register_status) { nil } let(:triage_status) { nil } @@ -349,7 +382,7 @@ let(:date_of_birth_year) { nil } let(:missing_nhs_number) { nil } let(:programme_status) { nil } - let(:programme_types) { [programme.type] } + let(:programme_types) { programmes.map(&:type) } let(:q) { nil } let(:register_status) { nil } let(:session_status) { "vaccinated" } @@ -388,7 +421,7 @@ let(:date_of_birth_year) { nil } let(:missing_nhs_number) { nil } let(:programme_status) { nil } - let(:programme_types) { [programme.type] } + let(:programme_types) { programmes.map(&:type) } let(:q) { nil } let(:register_status) { nil } let(:session_status) { nil } @@ -410,7 +443,7 @@ let(:date_of_birth_year) { nil } let(:missing_nhs_number) { nil } let(:programme_status) { nil } - let(:programme_types) { [programme.type] } + let(:programme_types) { programmes.map(&:type) } let(:q) { nil } let(:register_status) { nil } let(:triage_status) { nil } @@ -611,7 +644,7 @@ let(:date_of_birth_year) { nil } let(:year_groups) { nil } let(:missing_nhs_number) { false } - let(:programme_types) { [programme.type] } + let(:programme_types) { programmes.map(&:type) } let(:team) { create(:team) } let(:programme) { create(:programme, :flu) } From 9037de10833313ad90cf543eb1660793d42a9119 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 6 Aug 2025 16:38:00 +0100 Subject: [PATCH 50/58] Add filter to view children who have aged out This adds a new filter that allows users to see patients who have aged out of all the programmes, but may still be around from previous academic years. Jira-Issue: MAV-1513 --- .../app_patient_search_form_component.rb | 9 +++- .../concerns/patient_search_form_concern.rb | 1 + app/forms/patient_search_form.rb | 5 +- spec/features/manage_children_spec.rb | 25 +++++++++ .../parental_consent_manual_matching_spec.rb | 9 +++- spec/forms/patient_search_form_spec.rb | 51 +++++++++++++++++++ 6 files changed, 97 insertions(+), 3 deletions(-) diff --git a/app/components/app_patient_search_form_component.rb b/app/components/app_patient_search_form_component.rb index c5a06093e2..bad9edf34c 100644 --- a/app/components/app_patient_search_form_component.rb +++ b/app/components/app_patient_search_form_component.rb @@ -157,6 +157,13 @@ class AppPatientSearchFormComponent < ViewComponent::Base multiple: false, link_errors: true, label: { text: "Children missing an NHS number" } %> + + <%= f.govuk_check_box :aged_out_of_programmes, + 1, 0, + checked: form.aged_out_of_programmes, + multiple: false, + link_errors: true, + label: { text: "Children aged out of programmes" } %> <% end %> <% if show_buttons_in_details? %> @@ -223,7 +230,7 @@ def initialize( def open_details? @form.date_of_birth_year.present? || @form.date_of_birth_month.present? || @form.date_of_birth_day.present? || @form.missing_nhs_number || - @form.archived + @form.archived || @form.aged_out_of_programmes end def show_buttons_in_details? diff --git a/app/controllers/concerns/patient_search_form_concern.rb b/app/controllers/concerns/patient_search_form_concern.rb index d81f45dcac..9d50d40c77 100644 --- a/app/controllers/concerns/patient_search_form_concern.rb +++ b/app/controllers/concerns/patient_search_form_concern.rb @@ -21,6 +21,7 @@ def set_patient_search_form def patient_search_form_params params.permit( :_clear, + :aged_out_of_programmes, :archived, :date_of_birth_day, :date_of_birth_month, diff --git a/app/forms/patient_search_form.rb b/app/forms/patient_search_form.rb index a957a7dfa0..06a78cf1df 100644 --- a/app/forms/patient_search_form.rb +++ b/app/forms/patient_search_form.rb @@ -4,6 +4,7 @@ class PatientSearchForm < SearchForm attr_accessor :current_user attr_writer :academic_year + attribute :aged_out_of_programmes, :boolean attribute :archived, :boolean attribute :consent_statuses, array: true attribute :date_of_birth_day, :integer @@ -84,7 +85,9 @@ def filter_year_groups(scope) end def filter_aged_out_of_programmes(scope) - if @session || archived + if aged_out_of_programmes + scope.not_appear_in_programmes(team.programmes, academic_year:) + elsif @session || archived scope else # Archived patients won't appear in programmes, so we need to diff --git a/spec/features/manage_children_spec.rb b/spec/features/manage_children_spec.rb index ddf6d8a221..584acb18e3 100644 --- a/spec/features/manage_children_spec.rb +++ b/spec/features/manage_children_spec.rb @@ -17,6 +17,17 @@ then_i_see_the_activity_log end + scenario "Viewing children who have aged out" do + given_patients_exist + and_todays_date_is_in_the_far_future + + when_i_click_on_children + then_i_see_no_children + + when_i_click_on_view_aged_out_children + then_i_see_the_children + end + scenario "Adding an NHS number" do given_patients_exist and_sync_vaccination_records_to_nhs_feature_is_enabled @@ -198,6 +209,10 @@ def and_the_vaccination_has_been_synced_to_nhs perform_enqueued_jobs(only: SyncVaccinationRecordToNHSJob) end + def and_todays_date_is_in_the_far_future + travel 13.years + end + def when_a_deceased_patient_exists session = create(:session, team: @team, programmes: [@programme]) @@ -227,6 +242,16 @@ def then_i_see_the_children expect(page).to have_content(/\d+ children/) end + def then_i_see_no_children + expect(page).to have_content("No children") + end + + def when_i_click_on_view_aged_out_children + find(".nhsuk-details__summary").click + check "Children aged out of programmes" + click_on "Search" + end + def when_i_click_on_a_child click_on "SMITH, John" end diff --git a/spec/features/parental_consent_manual_matching_spec.rb b/spec/features/parental_consent_manual_matching_spec.rb index 947a32e857..815c0b45cd 100644 --- a/spec/features/parental_consent_manual_matching_spec.rb +++ b/spec/features/parental_consent_manual_matching_spec.rb @@ -35,7 +35,7 @@ when_i_choose_a_consent_response then_i_am_on_the_consent_matching_page - when_i_search_for_the_child + when_i_search_for_the_aged_out_child and_i_select_the_child_record then_i_can_review_the_match @@ -112,6 +112,13 @@ def when_i_search_for_the_child click_button "Search" end + def when_i_search_for_the_aged_out_child + fill_in "Search", with: @patient.given_name + find(".nhsuk-details__summary").click + check "Children aged out of programmes" + click_button "Search" + end + def and_i_select_the_child_record click_link @patient.full_name end diff --git a/spec/forms/patient_search_form_spec.rb b/spec/forms/patient_search_form_spec.rb index 5281fa0224..4803ddd698 100644 --- a/spec/forms/patient_search_form_spec.rb +++ b/spec/forms/patient_search_form_spec.rb @@ -19,6 +19,7 @@ let(:programmes) { [create(:programme, :flu)] } let(:team) { create(:team, programmes:) } + let(:aged_out_of_programmes) { nil } let(:archived) { nil } let(:consent_statuses) { nil } let(:date_of_birth_day) { Date.current.day } @@ -36,6 +37,7 @@ let(:params) do { + aged_out_of_programmes:, archived:, consent_statuses:, date_of_birth_day:, @@ -64,6 +66,55 @@ expect { form.apply(scope) }.not_to raise_error end + context "filtering on aged out of programmes" do + let(:consent_statuses) { nil } + let(:date_of_birth_day) { nil } + let(:date_of_birth_month) { nil } + let(:date_of_birth_year) { nil } + let(:missing_nhs_number) { nil } + let(:programme_status) { nil } + let(:q) { nil } + let(:register_status) { nil } + let(:session_status) { nil } + let(:triage_status) { nil } + let(:year_groups) { nil } + + let(:programmes) { [create(:programme, :flu)] } + let(:location) { create(:school, programmes:, year_groups: [11, 12]) } + let(:session_for_patients) { create(:session, location:, programmes:) } + + let!(:aged_out_patient) do + create(:patient, session: session_for_patients, year_group: 12) + end + let!(:not_aged_out_patient) do + create(:patient, session: session_for_patients, year_group: 11) + end + + context "when not filtering on aged out patients" do + let(:aged_out_of_programmes) { nil } + + it "includes the not aged out patient" do + expect(form.apply(scope)).to include(not_aged_out_patient) + end + + it "doesn't include the aged out patient" do + expect(form.apply(scope)).not_to include(aged_out_patient) + end + end + + context "when filtering on aged out patients" do + let(:aged_out_of_programmes) { true } + + it "doesn't include the not aged out patient" do + expect(form.apply(scope)).not_to include(not_aged_out_patient) + end + + it "includes the aged out patient" do + expect(form.apply(scope)).to include(aged_out_patient) + end + end + end + context "filtering on archived" do let(:consent_statuses) { nil } let(:date_of_birth_day) { nil } From 80aa5ebb9e29279e3718f9c3bb97874d01eb288d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 Aug 2025 21:30:40 +0000 Subject: [PATCH 51/58] Bump good_job from 4.11.1 to 4.11.2 Bumps [good_job](https://github.com/bensheldon/good_job) from 4.11.1 to 4.11.2. - [Release notes](https://github.com/bensheldon/good_job/releases) - [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md) - [Commits](https://github.com/bensheldon/good_job/compare/v4.11.1...v4.11.2) --- updated-dependencies: - dependency-name: good_job dependency-version: 4.11.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 835c21ddf4..8506b70dc2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -270,7 +270,7 @@ GEM raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) - good_job (4.11.1) + good_job (4.11.2) activejob (>= 6.1.0) activerecord (>= 6.1.0) concurrent-ruby (>= 1.3.1) From 6673dcedb192940459efcf0afe9c1ff577d7218c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 Aug 2025 21:32:37 +0000 Subject: [PATCH 52/58] Bump aws-sdk-ec2 from 1.545.0 to 1.546.0 Bumps [aws-sdk-ec2](https://github.com/aws/aws-sdk-ruby) from 1.545.0 to 1.546.0. - [Release notes](https://github.com/aws/aws-sdk-ruby/releases) - [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-ec2/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-ruby/commits) --- updated-dependencies: - dependency-name: aws-sdk-ec2 dependency-version: 1.546.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 835c21ddf4..a02b002c04 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -113,7 +113,7 @@ GEM ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1142.0) + aws-partitions (1.1143.0) aws-sdk-accessanalyzer (1.76.0) aws-sdk-core (~> 3, >= 3.228.0) aws-sigv4 (~> 1.5) @@ -125,7 +125,7 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-ec2 (1.545.0) + aws-sdk-ec2 (1.546.0) aws-sdk-core (~> 3, >= 3.228.0) aws-sigv4 (~> 1.5) aws-sdk-ecr (1.108.0) From 2636e07dc218e5863f397e34e441abd05e422f03 Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Tue, 5 Aug 2025 13:11:45 +0100 Subject: [PATCH 53/58] Refactor notice text into helper function This is an effort to deduplicate the hardcoded values of the important notice text. In particular, this is triggered by the introduction of the notice for patients who don't want their parents notified after self-consent; this requires dynamic content. --- app/components/app_notices_table_component.rb | 82 ++------- app/components/app_patient_card_component.rb | 20 +-- app/controllers/imports/notices_controller.rb | 3 +- app/helpers/patients_helper.rb | 47 +++++ app/models/patient.rb | 9 +- app/views/imports/notices/index.html.erb | 4 +- spec/helpers/patients_helper_spec.rb | 170 ++++++++++++++++++ 7 files changed, 249 insertions(+), 86 deletions(-) diff --git a/app/components/app_notices_table_component.rb b/app/components/app_notices_table_component.rb index 5406bc6980..e9b4c81d1a 100644 --- a/app/components/app_notices_table_component.rb +++ b/app/components/app_notices_table_component.rb @@ -5,84 +5,38 @@ def initialize( deceased_patients:, invalidated_patients:, restricted_patients:, - gillick_no_notify_patients: + has_vaccination_records_dont_notify_parents_patients: ) super @deceased_patients = deceased_patients @invalidated_patients = invalidated_patients @restricted_patients = restricted_patients - @gillick_no_notify_patients = gillick_no_notify_patients + @has_vaccination_records_dont_notify_parents_patients = + has_vaccination_records_dont_notify_parents_patients end def render? @deceased_patients.present? || @invalidated_patients.present? || - @restricted_patients.present? || @gillick_no_notify_patients.present? + @restricted_patients.present? || + @has_vaccination_records_dont_notify_parents_patients.present? end private def notices - ( - deceased_notices + invalidated_notices + restricted_notices + - gillick_no_notify_notices - ).sort_by { _1[:date_time] }.reverse - end - - def deceased_notices - @deceased_patients.map do |patient| - { - patient:, - date_time: patient.date_of_death_recorded_at, - message: "Record updated with child’s date of death" - } - end - end - - def invalidated_notices - @invalidated_patients.map do |patient| - { - patient:, - date_time: patient.invalidated_at, - message: "Record flagged as invalid" - } - end - end - - def restricted_notices - @restricted_patients.map do |patient| - { - patient:, - date_time: patient.restricted_at, - message: "Record flagged as sensitive" - } - end - end - - def gillick_no_notify_notices - - @gillick_no_notify_patients.map do |patient| - vaccination_records = patient.vaccination_records.includes(:programme).select { it.notify_parents == false } - - { - patient:, - date_time: - patient - .vaccination_records - .reject(&:notify_parents?) - .max_by(&:created_at) - &.created_at || Time.current, - message: - "Child gave consent for #{format_vaccinations(vaccination_records)} under Gillick competence and " \ - "does not want their parents to be notified. " \ - "These records will not be automatically synced with GP records. " \ - "Your team must let the child’s GP know they were vaccinated." - } - end - end - - def format_vaccinations(vaccination_records) - "#{vaccination_records.map(&:programme).map(&:name).to_sentence} " \ - "#{"vaccination".pluralize(vaccination_records.length)}" + all_patients = + ( + @deceased_patients + @invalidated_patients + @restricted_patients + + @has_vaccination_records_dont_notify_parents_patients + ).uniq + + notices = + all_patients.flat_map do |patient| + helpers + .patient_important_notices(patient) + .map { |notification| notification.merge(patient:) } + end + notices.sort_by { it[:date_time] }.reverse end end diff --git a/app/components/app_patient_card_component.rb b/app/components/app_patient_card_component.rb index 9ee3e243c7..026eed4d7e 100644 --- a/app/components/app_patient_card_component.rb +++ b/app/components/app_patient_card_component.rb @@ -4,23 +4,9 @@ class AppPatientCardComponent < ViewComponent::Base erb_template <<-ERB <%= render AppCardComponent.new(heading_level:, section: true) do |card| %> <% card.with_heading { "Child’s details" } %> - - <% if patient.date_of_death.present? %> - <%= render AppStatusComponent.new( - text: "Record updated with child’s date of death" - ) %> - <% end %> - - <% if patient.invalidated? %> - <%= render AppStatusComponent.new( - text: "Record flagged as invalid" - ) %> - <% end %> - - <% if patient.restricted? %> - <%= render AppStatusComponent.new( - text: "Record flagged as sensitive" - ) %> + + <% helpers.patient_important_notices(patient).each do |notification| %> + <%= render AppStatusComponent.new(text: notification[:message]) %> <% end %> <%= render AppChildSummaryComponent.new( diff --git a/app/controllers/imports/notices_controller.rb b/app/controllers/imports/notices_controller.rb index 12e9602997..a3955b84a3 100644 --- a/app/controllers/imports/notices_controller.rb +++ b/app/controllers/imports/notices_controller.rb @@ -9,6 +9,7 @@ def index @deceased_patients = policy_scope(Patient).deceased @invalidated_patients = policy_scope(Patient).invalidated @restricted_patients = policy_scope(Patient).restricted - @gillick_no_notify_patients = policy_scope(Patient).gillick_no_notify + @has_vaccination_records_dont_notify_parents_patients = + policy_scope(Patient).has_vaccination_records_dont_notify_parents end end diff --git a/app/helpers/patients_helper.rb b/app/helpers/patients_helper.rb index 32420ffae4..9957fb5c27 100644 --- a/app/helpers/patients_helper.rb +++ b/app/helpers/patients_helper.rb @@ -52,4 +52,51 @@ def patient_year_group(patient, academic_year:) def patient_parents(patient) format_parents_with_relationships(patient.parent_relationships) end + + def patient_important_notices(patient) + notifications = [] + + if patient.deceased? + notifications << { + date_time: patient.date_of_death_recorded_at, + message: "Record updated with child’s date of death" + } + end + + if patient.invalidated? + notifications << { + date_time: patient.invalidated_at, + message: "Record flagged as invalid" + } + end + + if patient.restricted? + notifications << { + date_time: patient.restricted_at, + message: "Record flagged as sensitive" + } + end + + no_notify_vaccination_records = + patient.vaccination_records.select { it.notify_parents == false } + if no_notify_vaccination_records.any? + notifications << { + date_time: no_notify_vaccination_records.maximum(:performed_at), + message: + "Child gave consent for #{format_vaccinations(no_notify_vaccination_records)} under Gillick competence and " \ + "does not want their parents to be notified. " \ + "These records will not be automatically synced with GP records. " \ + "Your team must let the child's GP know they were vaccinated." + } + end + + notifications.sort_by { |notification| notification[:date_time] }.reverse + end + + private + + def format_vaccinations(vaccination_records) + "#{vaccination_records.map(&:programme).map(&:name).to_sentence} " \ + "#{"vaccination".pluralize(vaccination_records.length)}" + end end diff --git a/app/models/patient.rb b/app/models/patient.rb index ac6ec89a79..1611138b1d 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -122,7 +122,7 @@ class Patient < ApplicationRecord scope :not_deceased, -> { where(date_of_death: nil) } scope :restricted, -> { where.not(restricted_at: nil) } - scope :gillick_no_notify, + scope :has_vaccination_records_dont_notify_parents, -> do joins(:vaccination_records).where( vaccination_records: { @@ -132,7 +132,12 @@ class Patient < ApplicationRecord end scope :with_notice, - -> { (deceased + restricted + invalidated + gillick_no_notify).uniq } + -> do + ( + deceased + restricted + invalidated + + has_vaccination_records_dont_notify_parents + ).uniq + end scope :appear_in_programmes, ->(programmes, academic_year:) do diff --git a/app/views/imports/notices/index.html.erb b/app/views/imports/notices/index.html.erb index 2c5c8689af..ae5e446779 100644 --- a/app/views/imports/notices/index.html.erb +++ b/app/views/imports/notices/index.html.erb @@ -4,12 +4,12 @@ <%= render AppImportsNavigationComponent.new(active: :notices) %> -<% if @deceased_patients.any? || @invalidated_patients.any? || @restricted_patients.any? || @gillick_no_notify_patients.any? %> +<% if @deceased_patients.any? || @invalidated_patients.any? || @restricted_patients.any? || @has_vaccination_records_dont_notify_parents_patients.any? %> <%= render AppNoticesTableComponent.new( deceased_patients: @deceased_patients, invalidated_patients: @invalidated_patients, restricted_patients: @restricted_patients, - gillick_no_notify_patients: @gillick_no_notify_patients, + has_vaccination_records_dont_notify_parents_patients: @has_vaccination_records_dont_notify_parents_patients, ) %> <% else %>

<%= t(".no_results") %>

diff --git a/spec/helpers/patients_helper_spec.rb b/spec/helpers/patients_helper_spec.rb index bc0a84d86c..246b5f707d 100644 --- a/spec/helpers/patients_helper_spec.rb +++ b/spec/helpers/patients_helper_spec.rb @@ -121,4 +121,174 @@ end end end + + describe "patient_important_notices" do + subject(:notifications) { helper.patient_important_notices(patient) } + + let(:patient) { create(:patient) } + let(:programme) { create(:programme, :hpv) } + + context "when patient has no special status" do + it "returns empty array" do + expect(notifications).to eq([]) + end + end + + context "when patient is deceased" do + let(:recorded_at) { Date.new(2025, 2, 1) } + + before do + patient.update!( + date_of_death: Date.new(2025, 1, 1), + date_of_death_recorded_at: recorded_at + ) + end + + it "returns deceased notification" do + expect(notifications.count).to eq(1) + expect(notifications.first).to include( + date_time: recorded_at, + message: "Record updated with child’s date of death" + ) + end + end + + context "when patient is invalidated" do + let(:invalidated_at) { Date.new(2025, 1, 1) } + + before { patient.update!(invalidated_at:) } + + it "returns invalidated notification" do + expect(notifications.count).to eq(1) + expect(notifications.first).to include( + date_time: invalidated_at, + message: "Record flagged as invalid" + ) + end + end + + context "when patient is restricted" do + let(:restricted_at) { Date.new(2025, 1, 1) } + + before { patient.update!(restricted_at:) } + + it "returns restricted notification" do + expect(notifications.count).to eq(1) + expect(notifications.first).to include( + date_time: restricted_at, + message: "Record flagged as sensitive" + ) + end + end + + context "when patient has gillick no notify vaccination records" do + let(:performed_at) { Date.new(2025, 1, 1) } + + let(:vaccination_record) do + create( + :vaccination_record, + patient: patient, + programme: programme, + notify_parents: false, + performed_at: + ) + end + + before { vaccination_record } + + it "returns gillick no notify notification" do + expect(notifications.count).to eq(1) + notification = notifications.first + expect(notification[:date_time]).to eq(performed_at) + expect(notification[:message]).to include( + "Child gave consent for HPV vaccination under Gillick competence" + ) + expect(notification[:message]).to include( + "does not want their parents to be notified" + ) + end + end + + context "when patient has multiple vaccination records with the same notify_parents values" do + let(:other_programme) { create(:programme, :flu) } + + let(:notify_record) do + create( + :vaccination_record, + patient:, + programme: other_programme, + notify_parents: false + ) + end + let(:no_notify_record) do + create(:vaccination_record, patient:, programme:, notify_parents: false) + end + + before do + notify_record + no_notify_record + end + + it "only includes records with notify_parents false in the message" do + expect(notifications.count).to eq(1) + expect(notifications.first[:message]).to include( + "Flu and HPV vaccinations" + ) + end + end + + context "when patient has multiple vaccination records with different notify_parents values" do + let(:other_programme) { create(:programme, :flu) } + + let(:notify_record) do + create( + :vaccination_record, + patient:, + programme: other_programme, + notify_parents: true + ) + end + let(:no_notify_record) do + create(:vaccination_record, patient:, programme:, notify_parents: false) + end + + before do + notify_record + no_notify_record + end + + it "only includes records with notify_parents false in the message" do + expect(notifications.count).to eq(1) + expect(notifications.first[:message]).to include("HPV vaccination") + end + end + + context "when patient has multiple notification types" do + let(:deceased_at) { Date.new(2025, 1, 3) } + let(:restricted_at) { Date.new(2025, 1, 2) } + let(:invalidated_at) { Date.new(2025, 1, 1) } + + before do + patient.update!( + date_of_death: Date.current, + date_of_death_recorded_at: deceased_at, + restricted_at: restricted_at, + invalidated_at: invalidated_at + ) + end + + it "returns all notifications sorted by date_time descending" do + expect(notifications.count).to eq(3) + + # Should be sorted by date_time in reverse order (most recent first) + expect(notifications[0][:date_time]).to eq(deceased_at) + expect(notifications[1][:date_time]).to eq(restricted_at) + expect(notifications[2][:date_time]).to eq(invalidated_at) + + expect(notifications[0][:message]).to include("date of death") + expect(notifications[1][:message]).to include("flagged as sensitive") + expect(notifications[2][:message]).to include("flagged as invalid") + end + end + end end From 49a76cf60ba76e2554e829f3dcf6c64b8077a31d Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Wed, 6 Aug 2025 11:29:56 +0100 Subject: [PATCH 54/58] Improve performance Include vaccination records, and associated programmes in the high level patient query. This means that there no longer needs to be a database request for every patient when loading the Important notices page, because `PatientsHelper.patient_important_notices` can now work on all the patients entirely in memory --- app/controllers/imports/notices_controller.rb | 17 +++++++++++++---- spec/helpers/patients_helper_spec.rb | 7 ++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/app/controllers/imports/notices_controller.rb b/app/controllers/imports/notices_controller.rb index a3955b84a3..be75f843bb 100644 --- a/app/controllers/imports/notices_controller.rb +++ b/app/controllers/imports/notices_controller.rb @@ -6,10 +6,19 @@ class Imports::NoticesController < ApplicationController def index authorize :notices - @deceased_patients = policy_scope(Patient).deceased - @invalidated_patients = policy_scope(Patient).invalidated - @restricted_patients = policy_scope(Patient).restricted + @deceased_patients = + policy_scope(Patient).deceased.includes(vaccination_records: :programme) + @invalidated_patients = + policy_scope(Patient).invalidated.includes( + vaccination_records: :programme + ) + @restricted_patients = + policy_scope(Patient).restricted.includes(vaccination_records: :programme) @has_vaccination_records_dont_notify_parents_patients = - policy_scope(Patient).has_vaccination_records_dont_notify_parents + policy_scope( + Patient + ).has_vaccination_records_dont_notify_parents.includes( + vaccination_records: :programme + ) end end diff --git a/spec/helpers/patients_helper_spec.rb b/spec/helpers/patients_helper_spec.rb index 246b5f707d..a3e9b3a3a3 100644 --- a/spec/helpers/patients_helper_spec.rb +++ b/spec/helpers/patients_helper_spec.rb @@ -123,9 +123,14 @@ end describe "patient_important_notices" do - subject(:notifications) { helper.patient_important_notices(patient) } + subject(:notifications) do + helper.patient_important_notices(patient_with_preloaded_associations) + end let(:patient) { create(:patient) } + let(:patient_with_preloaded_associations) do + Patient.includes(vaccination_records: :programme).find(patient.id) + end let(:programme) { create(:programme, :hpv) } context "when patient has no special status" do From 3d2578fe26681431c0b4de89157536f2b0d2211b Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Tue, 5 Aug 2025 13:44:30 +0100 Subject: [PATCH 55/58] Force exclamation icon to stay the same size With longer text in an `AppStatusComponent`, the blue exclamation mark icon was getting smaller. This forces it to stay the same size, and not shrink --- app/assets/stylesheets/components/_status.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/assets/stylesheets/components/_status.scss b/app/assets/stylesheets/components/_status.scss index 7b4c6ceaba..fd9c90a21f 100644 --- a/app/assets/stylesheets/components/_status.scss +++ b/app/assets/stylesheets/components/_status.scss @@ -8,8 +8,11 @@ gap: nhsuk-spacing(1); .nhsuk-icon { + flex-shrink: 0; + height: 1.5em; margin-left: nhsuk-spacing(-1); margin-top: nhsuk-spacing(-1); + width: 1.5em; } &--aqua-green { From f84e966cd4bb7999078bdf8a23987e38f491f713 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Wed, 6 Aug 2025 22:18:07 +0100 Subject: [PATCH 56/58] Update cohort import filename These are no longer specific for perf tests, and I found myself manually adding the sizes and a timestamp, so it seems to make sense to automate that. --- lib/generate/cohort_imports.rb | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/generate/cohort_imports.rb b/lib/generate/cohort_imports.rb index 0549e75fff..4fd8bf6f57 100644 --- a/lib/generate/cohort_imports.rb +++ b/lib/generate/cohort_imports.rb @@ -74,8 +74,19 @@ def patients delegate :organisation, to: :team def cohort_import_csv_filepath + timestamp = Time.current.strftime("%Y%m%d%H%M%S") + size = + ActiveSupport::NumberHelper.number_to_human( + @patient_count, + units: { + thousand: "k", + million: "m" + }, + format: "%n%u" + ) Rails.root.join( - "tmp/perf-test-cohort-import-#{organisation.ods_code}-#{programme.type}.csv" + "tmp/cohort-import-" \ + "#{organisation.ods_code}-#{programme.type}-#{size}-#{timestamp}.csv" ) end @@ -174,7 +185,7 @@ def build_patient end end - def date_of_birth_for_year(year_group, academic_year: AcademicYear.current) + def date_of_birth_for_year(year_group, academic_year: AcademicYear.pending) if year_group < 12 rand( year_group.to_birth_academic_year( From 57ede3cd0cc84b74c6a3d3afc32a2a697b4ee8da Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Wed, 6 Aug 2025 22:20:13 +0100 Subject: [PATCH 57/58] Add ods code option for cli generate cohort-import Before this defaulted to A9A5A always. Now we can generate a cohort import for any organisation that is in Mavis. --- app/lib/mavis_cli/generate/cohort_imports.rb | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/lib/mavis_cli/generate/cohort_imports.rb b/app/lib/mavis_cli/generate/cohort_imports.rb index a2017a7995..6f27b312d4 100644 --- a/app/lib/mavis_cli/generate/cohort_imports.rb +++ b/app/lib/mavis_cli/generate/cohort_imports.rb @@ -11,18 +11,25 @@ class CohortImports < Dry::CLI::Command required: true, default: 10, desc: "Number of patients to create" + option :ods_code, + type: :string, + default: "A9A5A", + desc: "ODS code of the organisation to use for the cohort import" - def call(patients:) + def call(patients:, ods_code:) MavisCLI.load_rails patient_count = patients.to_i - puts "Generating cohort import with #{patient_count} patients..." progress_bar = MavisCLI.progress_bar(patient_count) + puts "Generating cohort import for ods code #{ods_code} with" \ + " #{patient_count} patients..." + result = ::Generate::CohortImports.call( - patient_count: patient_count, - progress_bar: progress_bar + ods_code:, + patient_count:, + progress_bar: ) puts "\nCohort import CSV generated: #{result}" From afb23688e784c534d5af8ca3388c504460b9c342 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Wed, 6 Aug 2025 22:21:57 +0100 Subject: [PATCH 58/58] Add spec for cli generate cohort-import These were missing, but they're useful to ensure the cli command continues to work. --- .../cli_generate_cohort_imports_spec.rb | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 spec/features/cli_generate_cohort_imports_spec.rb diff --git a/spec/features/cli_generate_cohort_imports_spec.rb b/spec/features/cli_generate_cohort_imports_spec.rb new file mode 100644 index 0000000000..665d003718 --- /dev/null +++ b/spec/features/cli_generate_cohort_imports_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +describe "mavis generate cohort-imports" do + it "generates a cohort import CSV file" do + given_an_organisation_exists + and_there_are_three_sessions_in_the_organisation + when_i_run_the_generate_cohort_imports_command + then_a_cohort_import_csv_file_is_created + end + + def given_an_organisation_exists + @programme = Programme.hpv.first || create(:programme, :hpv) + @organisation = create(:organisation, ods_code: "R1Y") + end + + def and_there_are_three_sessions_in_the_organisation + @sessions = + create_list( + :session, + 3, + organisation: @organisation, + programmes: [@programme] + ) + end + + def when_i_run_the_generate_cohort_imports_command + freeze_time do + @output = + capture_output do + Dry::CLI.new(MavisCLI).call( + arguments: %w[generate cohort-imports -o R1Y -p 100] + ) + end + @timestamp = Time.current.strftime("%Y%m%d%H%M%S") + end + end + + def then_a_cohort_import_csv_file_is_created + expect(@output).to include( + "Generating cohort import for ods code R1Y with 100 patients" + ) + expect(@output).to match( + /Cohort import CSV generated:.*cohort-import-R1Y-hpv-100-#{@timestamp}.csv/ + ) + + expect( + File.readlines( + Rails.root.join("tmp", "cohort-import-R1Y-hpv-100-#{@timestamp}.csv") + ).length + ).to eq 101 + end +end