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 %>
+ <% 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