From 65984949706929273a570d80414b67d89d78976d Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Mon, 24 Mar 2025 23:18:13 +0100 Subject: [PATCH 1/9] Remove usage of SessionOutcome This removes usage of the `all` and `latest` methods of the `SessionOutcome` in preparation for the class being replaced with a model storing a cached version of the status. --- app/controllers/patients_controller.rb | 7 +++++-- app/lib/reports/careplus_exporter.rb | 6 +++++- app/models/patient.rb | 7 +++++-- app/models/patient_session.rb | 14 +++++++++----- app/models/patient_session/register_outcome.rb | 10 +++++++--- app/models/school_move.rb | 5 +++-- 6 files changed, 34 insertions(+), 15 deletions(-) diff --git a/app/controllers/patients_controller.rb b/app/controllers/patients_controller.rb index 1d8188e45d..d6333560cb 100644 --- a/app/controllers/patients_controller.rb +++ b/app/controllers/patients_controller.rb @@ -43,8 +43,11 @@ def update if organisation_id.nil? @patient .patient_sessions - .preload_for_status - .includes(:gillick_assessments, :session_attendances) + .includes( + :gillick_assessments, + :session_attendances, + :vaccination_records + ) .where(session: old_organisation.sessions) .find_each(&:destroy_if_safe!) end diff --git a/app/lib/reports/careplus_exporter.rb b/app/lib/reports/careplus_exporter.rb index e54c9ac34a..f9ba94dede 100644 --- a/app/lib/reports/careplus_exporter.rb +++ b/app/lib/reports/careplus_exporter.rb @@ -117,7 +117,11 @@ def rows(patient_session:) patient = patient_session.patient vaccination_records = - patient_session.session_outcome.all[programme].select(&:administered?) + patient + .latest_vaccination_records(programme:) + .select do + it.administered? && it.session_id == patient_session.session_id + end if vaccination_records.any? [existing_row(patient:, patient_session:, vaccination_records:)] diff --git a/app/models/patient.rb b/app/models/patient.rb index 924cc455a8..2820142daa 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -445,8 +445,11 @@ def destroy_childless_parents def clear_sessions_for_current_academic_year! patient_sessions - .preload_for_status - .includes(:gillick_assessments, :session_attendances) + .includes( + :gillick_assessments, + :session_attendances, + :vaccination_records + ) .where(session: sessions_for_current_academic_year) .find_each(&:destroy_if_safe!) end diff --git a/app/models/patient_session.rb b/app/models/patient_session.rb index eaa814c6cc..635b517919 100644 --- a/app/models/patient_session.rb +++ b/app/models/patient_session.rb @@ -31,18 +31,22 @@ class PatientSession < ApplicationRecord belongs_to :patient belongs_to :session + has_many :gillick_assessments, -> { order(:created_at) } + has_many :pre_screenings, -> { order(:created_at) } + has_one :location, through: :session has_one :team, through: :session has_one :organisation, through: :session has_many :session_attendances, dependent: :destroy - has_many :gillick_assessments, -> { order(:created_at) } - has_many :pre_screenings, -> { order(:created_at) } - has_many :session_notifications, -> { where(session_id: _1.session_id) }, through: :patient + has_many :vaccination_records, + -> { where(session_id: _1.session_id) }, + through: :patient + has_and_belongs_to_many :immunisation_imports scope :notification_not_sent, @@ -127,8 +131,8 @@ class PatientSession < ApplicationRecord end def safe_to_destroy? - programmes.none? { session_outcome.all[it].any? } && - gillick_assessments.empty? && session_attendances.none?(&:attending?) + vaccination_records.empty? && gillick_assessments.empty? && + session_attendances.none?(&:attending?) end def destroy_if_safe! diff --git a/app/models/patient_session/register_outcome.rb b/app/models/patient_session/register_outcome.rb index 485fbf2edb..677c3d20f7 100644 --- a/app/models/patient_session/register_outcome.rb +++ b/app/models/patient_session/register_outcome.rb @@ -45,10 +45,10 @@ def latest attr_reader :patient_session - delegate :programmes, + delegate :patient, + :programmes, :session, :session_attendances, - :session_outcome, to: :patient_session def session_date @@ -56,6 +56,10 @@ def session_date end def all_programmes_have_outcome? - programmes.none? { session_outcome.latest[it].nil? } + programmes.all? do |programme| + patient + .latest_vaccination_records(programme:) + .any? { it.session_id == session.id } + end end end diff --git a/app/models/school_move.rb b/app/models/school_move.rb index c473e239ae..72f314f572 100644 --- a/app/models/school_move.rb +++ b/app/models/school_move.rb @@ -65,9 +65,10 @@ def ignore! private def patient_sessions - patient.patient_sessions.preload_for_status.includes( + patient.patient_sessions.includes( :gillick_assessments, - :session_attendances + :session_attendances, + :vaccination_records ) end From 83bc98437e3fd399f6f50a85fedc80f8fe834e70 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 25 Mar 2025 08:13:04 +0100 Subject: [PATCH 2/9] Create PatientSession::SessionStatus This creates a new model that will represent the vaccination status of a patient/programme pair so the value can be queried in the database directly rather than needing to generate the status on the fly each time. --- .rubocop.yml | 3 ++ app/models/patient_session.rb | 1 + app/models/patient_session/session_status.rb | 39 +++++++++++++++ ...4_create_patient_session_session_status.rb | 19 +++++++ db/schema.rb | 12 ++++- .../patient_session_session_statuses.rb | 30 ++++++++++++ .../patient_session/session_status_spec.rb | 49 +++++++++++++++++++ 7 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 app/models/patient_session/session_status.rb create mode 100644 db/migrate/20250325065344_create_patient_session_session_status.rb create mode 100644 spec/factories/patient_session_session_statuses.rb create mode 100644 spec/models/patient_session/session_status_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 998d0a2f35..27ccac0aa2 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -18,6 +18,9 @@ AllCops: Layout/EmptyLineAfterMagicComment: Enabled: true +Layout/LineLength: + AllowedPatterns: [idx_on] + Lint/PercentStringArray: Exclude: - spec/models/dps_export_spec.rb diff --git a/app/models/patient_session.rb b/app/models/patient_session.rb index 635b517919..6a055cebe4 100644 --- a/app/models/patient_session.rb +++ b/app/models/patient_session.rb @@ -33,6 +33,7 @@ class PatientSession < ApplicationRecord has_many :gillick_assessments, -> { order(:created_at) } has_many :pre_screenings, -> { order(:created_at) } + has_many :session_statuses has_one :location, through: :session has_one :team, through: :session diff --git a/app/models/patient_session/session_status.rb b/app/models/patient_session/session_status.rb new file mode 100644 index 0000000000..5b98c483d7 --- /dev/null +++ b/app/models/patient_session/session_status.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: patient_session_session_statuses +# +# id :bigint not null, primary key +# status :integer default("none_yet"), not null +# patient_session_id :bigint not null +# programme_id :bigint not null +# +# Indexes +# +# idx_on_patient_session_id_programme_id_8777f5ba39 (patient_session_id,programme_id) UNIQUE +# index_patient_session_session_statuses_on_status (status) +# +# Foreign Keys +# +# fk_rails_... (patient_session_id => patient_sessions.id) ON DELETE => cascade +# fk_rails_... (programme_id => programmes.id) +# +class PatientSession::SessionStatus < ApplicationRecord + belongs_to :patient_session + belongs_to :programme + + enum :status, + { + none_yet: 0, + vaccinated: 1, + already_had: 2, + had_contraindications: 3, + refused: 4, + absent_from_session: 5, + unwell: 6, + absent_from_school: 7 + }, + default: :none_yet, + validate: true +end diff --git a/db/migrate/20250325065344_create_patient_session_session_status.rb b/db/migrate/20250325065344_create_patient_session_session_status.rb new file mode 100644 index 0000000000..e517b7194c --- /dev/null +++ b/db/migrate/20250325065344_create_patient_session_session_status.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreatePatientSessionSessionStatus < ActiveRecord::Migration[8.0] + def change + # rubocop:disable Rails/CreateTableWithTimestamps + create_table :patient_session_session_statuses do |t| + t.references :patient_session, + null: false, + index: false, + foreign_key: { + on_delete: :cascade + } + t.references :programme, null: false, index: false, foreign_key: true + t.integer :status, null: false, default: 0, index: true + t.index %i[patient_session_id programme_id], unique: true + end + # rubocop:enable Rails/CreateTableWithTimestamps + end +end diff --git a/db/schema.rb b/db/schema.rb index cb44f83d3e..a0b3467f42 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_03_24_195409) do +ActiveRecord::Schema[8.0].define(version: 2025_03_25_065344) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_trgm" @@ -554,6 +554,14 @@ t.index ["status"], name: "index_patient_consent_statuses_on_status" end + create_table "patient_session_session_statuses", force: :cascade do |t| + t.bigint "patient_session_id", null: false + t.bigint "programme_id", null: false + t.integer "status", default: 0, null: false + t.index ["patient_session_id", "programme_id"], name: "idx_on_patient_session_id_programme_id_8777f5ba39", unique: true + t.index ["status"], name: "index_patient_session_session_statuses_on_status" + end + create_table "patient_sessions", force: :cascade do |t| t.bigint "session_id", null: false t.bigint "patient_id", null: false @@ -889,6 +897,8 @@ add_foreign_key "parent_relationships", "patients" add_foreign_key "patient_consent_statuses", "patients", on_delete: :cascade add_foreign_key "patient_consent_statuses", "programmes" + add_foreign_key "patient_session_session_statuses", "patient_sessions", on_delete: :cascade + add_foreign_key "patient_session_session_statuses", "programmes" add_foreign_key "patient_sessions", "patients" add_foreign_key "patient_sessions", "sessions" add_foreign_key "patient_triage_statuses", "patients", on_delete: :cascade diff --git a/spec/factories/patient_session_session_statuses.rb b/spec/factories/patient_session_session_statuses.rb new file mode 100644 index 0000000000..8dd464bb7a --- /dev/null +++ b/spec/factories/patient_session_session_statuses.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: patient_session_session_statuses +# +# id :bigint not null, primary key +# status :integer default("none_yet"), not null +# patient_session_id :bigint not null +# programme_id :bigint not null +# +# Indexes +# +# idx_on_patient_session_id_programme_id_8777f5ba39 (patient_session_id,programme_id) UNIQUE +# index_patient_session_session_statuses_on_status (status) +# +# Foreign Keys +# +# fk_rails_... (patient_session_id => patient_sessions.id) ON DELETE => cascade +# fk_rails_... (programme_id => programmes.id) +# +FactoryBot.define do + factory :patient_session_session_status, + class: "PatientSession::SessionStatus" do + patient_session + programme + + traits_for_enum :status + end +end diff --git a/spec/models/patient_session/session_status_spec.rb b/spec/models/patient_session/session_status_spec.rb new file mode 100644 index 0000000000..158f1c1f18 --- /dev/null +++ b/spec/models/patient_session/session_status_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: patient_session_session_statuses +# +# id :bigint not null, primary key +# status :integer default("none_yet"), not null +# patient_session_id :bigint not null +# programme_id :bigint not null +# +# Indexes +# +# idx_on_patient_session_id_programme_id_8777f5ba39 (patient_session_id,programme_id) UNIQUE +# index_patient_session_session_statuses_on_status (status) +# +# Foreign Keys +# +# fk_rails_... (patient_session_id => patient_sessions.id) ON DELETE => cascade +# fk_rails_... (programme_id => programmes.id) +# +describe PatientSession::SessionStatus do + subject(:patient_session_session_status) do + build(:patient_session_session_status, patient_session:, programme:) + end + + let(:patient_session) { create(:patient_session, programmes: [programme]) } + let(:programme) { create(:programme) } + + it { should belong_to(:patient_session) } + it { should belong_to(:programme) } + + it do + expect(patient_session_session_status).to define_enum_for( + :status + ).with_values( + %i[ + none_yet + vaccinated + already_had + had_contraindications + refused + absent_from_session + unwell + absent_from_school + ] + ) + end +end From b7c9a819e208dafde647a214910e755a825f970a Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 25 Mar 2025 11:01:18 +0100 Subject: [PATCH 3/9] Add PatientSession::VaccinationStatus#assign_status This adds a method which refreshes the status of a patient-programme pair to be used in various other parts of the service. This is the main logic that replaces the `SessionOutcome` class, although the logic itself should be the same. --- app/lib/status_updater.rb | 36 ++++++++ app/models/patient_session/session_status.rb | 85 +++++++++++++++++++ app/models/session_attendance.rb | 2 + app/models/session_date.rb | 8 +- spec/lib/status_updater_spec.rb | 29 +++++-- .../patient_session/session_status_spec.rb | 57 +++++++++++++ 6 files changed, 211 insertions(+), 6 deletions(-) diff --git a/app/lib/status_updater.rb b/app/lib/status_updater.rb index 43943aeb79..ce13e2ad08 100644 --- a/app/lib/status_updater.rb +++ b/app/lib/status_updater.rb @@ -12,6 +12,7 @@ def initialize(patient: nil, session: nil) def call update_consent_statuses! + update_session_statuses! update_triage_statuses! update_vaccination_statuses! end @@ -47,6 +48,29 @@ def update_consent_statuses! end end + def update_session_statuses! + PatientSession::SessionStatus.import!( + %i[patient_session_id programme_id], + patient_session_statuses_to_import, + on_duplicate_key_ignore: true + ) + + PatientSession::SessionStatus + .where(patient_session_id: patient_sessions.select(:id)) + .includes(:consents, :triages, :vaccination_records, :session_attendance) + .find_in_batches(batch_size: 10_000) do |batch| + batch.each(&:assign_status) + + PatientSession::SessionStatus.import!( + batch.select(&:changed?), + on_duplicate_key_update: { + conflict_target: [:id], + columns: %i[status] + } + ) + end + end + def update_triage_statuses! Patient::TriageStatus.import!( %i[patient_id programme_id], @@ -106,6 +130,18 @@ def patient_statuses_to_import end end + def patient_session_statuses_to_import + @patient_session_statuses_to_import ||= + patient_sessions + .joins(:patient) + .pluck(:id, :"patients.birth_academic_year") + .flat_map do |patient_session_id, birth_academic_year| + programme_ids_per_birth_academic_year + .fetch(birth_academic_year, []) + .map { [patient_session_id, it] } + end + end + def programme_ids_per_birth_academic_year @programme_ids_per_birth_academic_year ||= Programme diff --git a/app/models/patient_session/session_status.rb b/app/models/patient_session/session_status.rb index 5b98c483d7..d5a45c914d 100644 --- a/app/models/patient_session/session_status.rb +++ b/app/models/patient_session/session_status.rb @@ -23,6 +23,23 @@ class PatientSession::SessionStatus < ApplicationRecord belongs_to :patient_session belongs_to :programme + has_one :patient, through: :patient_session + + has_many :consents, + -> do + not_invalidated.response_provided.eager_load(:parent, :patient) + end, + through: :patient + + has_many :triages, -> { not_invalidated }, through: :patient + + has_many :vaccination_records, -> { kept }, through: :patient + + has_one :session_attendance, + -> { today }, + through: :patient_session, + source: :session_attendances + enum :status, { none_yet: 0, @@ -36,4 +53,72 @@ class PatientSession::SessionStatus < ApplicationRecord }, default: :none_yet, validate: true + + def assign_status + self.status = + if status_should_be_vaccinated? + :vaccinated + elsif status_should_be_already_had? + :already_had + elsif status_should_be_had_contraindications? + :had_contraindications + elsif status_should_be_refused? + :refused + elsif status_should_be_absent_from_session? + :absent_from_session + elsif status_should_be_unwell? + :unwell + elsif status_should_be_absent_from_school? + :absent_from_school + else + :none_yet + end + end + + private + + def status_should_be_vaccinated? + vaccination_record&.administered? + end + + def status_should_be_already_had? + vaccination_record&.already_had? + end + + def status_should_be_had_contraindications? + vaccination_record&.contraindications? || triage&.do_not_vaccinate? + end + + def status_should_be_refused? + vaccination_record&.refused? || latest_consents.any?(&:response_refused?) + end + + def status_should_be_absent_from_session? + vaccination_record&.absent_from_session? || + session_attendance&.attending == false + end + + def status_should_be_unwell? + vaccination_record&.not_well? + end + + def status_should_be_absent_from_school? + vaccination_record&.absent_from_school? + end + + def latest_consents + @latest_consents ||= ConsentGrouper.call(consents, programme_id:) + end + + def triage + @triage ||= triages.reverse.find { it.programme_id == programme_id } + end + + def vaccination_record + @vaccination_record ||= + vaccination_records.reverse.find do + it.programme_id == programme_id && + it.session_id == patient_session.session_id + end + end end diff --git a/app/models/session_attendance.rb b/app/models/session_attendance.rb index db87ea1440..b1773b33a7 100644 --- a/app/models/session_attendance.rb +++ b/app/models/session_attendance.rb @@ -31,4 +31,6 @@ class SessionAttendance < ApplicationRecord has_one :session, through: :patient_session has_one :patient, through: :patient_session has_one :location, through: :session + + scope :today, -> { joins(:session_date).merge(SessionDate.today) } end diff --git a/app/models/session_date.rb b/app/models/session_date.rb index d8854091b2..c9670c4959 100644 --- a/app/models/session_date.rb +++ b/app/models/session_date.rb @@ -26,6 +26,8 @@ class SessionDate < ApplicationRecord scope :for_session, -> { where("session_id = sessions.id") } + scope :today, -> { where(value: Date.current) } + validates :value, uniqueness: { scope: :session @@ -35,7 +37,11 @@ class SessionDate < ApplicationRecord less_than_or_equal_to: :latest_possible_value } - delegate :today?, :future?, to: :value + delegate :today?, :past?, :future?, to: :value + + def today_or_past? + today? || past? + end def today_or_future? today? || future? diff --git a/spec/lib/status_updater_spec.rb b/spec/lib/status_updater_spec.rb index 2a1daca7f6..16cd0bd42e 100644 --- a/spec/lib/status_updater_spec.rb +++ b/spec/lib/status_updater_spec.rb @@ -3,7 +3,7 @@ describe StatusUpdater do subject(:call) { described_class.call } - before { create(:patient_session, patient:, programmes:) } + let!(:patient_session) { create(:patient_session, patient:, programmes:) } context "with an HPV session and ineligible patient" do let(:programmes) { [create(:programme, :hpv)] } @@ -17,9 +17,13 @@ expect { call }.not_to change(Patient::TriageStatus, :count) end - it "doesn't create any vaccination statuses" do + it "doesn't create any patient vaccination statuses" do expect { call }.not_to change(Patient::VaccinationStatus, :count) end + + it "doesn't create any patient session session statuses" do + expect { call }.not_to change(PatientSession::SessionStatus, :count) + end end context "with an HPV session and eligible patient" do @@ -36,10 +40,15 @@ expect(patient.triage_statuses.first).to be_not_required end - it "creates a vaccination status" do + it "creates a patient vaccination status" do expect { call }.to change(patient.vaccination_statuses, :count).by(1) expect(patient.vaccination_statuses.first).to be_none_yet end + + it "creates a patient session session vaccination status" do + expect { call }.to change(patient_session.session_statuses, :count).by(1) + expect(patient_session.session_statuses.first).to be_none_yet + end end context "with a doubles session and ineligible patient" do @@ -56,9 +65,13 @@ expect { call }.not_to change(Patient::TriageStatus, :count) end - it "doesn't create any vaccination statuses" do + it "doesn't create any patient vaccination statuses" do expect { call }.not_to change(Patient::VaccinationStatus, :count) end + + it "doesn't create any patient session session statuses" do + expect { call }.not_to change(PatientSession::SessionStatus, :count) + end end context "with an doubles session and eligible patient" do @@ -79,10 +92,16 @@ expect(patient.triage_statuses.second).to be_not_required end - it "creates a vaccination status for both programmes" do + it "creates a patient vaccination status for both programmes" do expect { call }.to change(patient.vaccination_statuses, :count).by(2) expect(patient.vaccination_statuses.first).to be_none_yet expect(patient.vaccination_statuses.second).to be_none_yet end + + it "creates a patient session session status for both programmes" do + expect { call }.to change(patient_session.session_statuses, :count).by(2) + expect(patient_session.session_statuses.first).to be_none_yet + expect(patient_session.session_statuses.second).to be_none_yet + end end end diff --git a/spec/models/patient_session/session_status_spec.rb b/spec/models/patient_session/session_status_spec.rb index 158f1c1f18..dea8bc2c88 100644 --- a/spec/models/patient_session/session_status_spec.rb +++ b/spec/models/patient_session/session_status_spec.rb @@ -46,4 +46,61 @@ ] ) end + + describe "#status" do + subject(:status) { patient_session_session_status.assign_status } + + let(:patient) { patient_session.patient } + let(:session) { patient_session.session } + + context "with no vaccination record" do + it { should be(:none_yet) } + end + + context "with a vaccination administered" do + before { create(:vaccination_record, patient:, session:, programme:) } + + it { should be(:vaccinated) } + end + + context "with a vaccination not administered" do + before do + create( + :vaccination_record, + :not_administered, + patient:, + session:, + programme: + ) + end + + it { should be(:unwell) } + end + + context "with a discarded vaccination administered" do + before do + create(:vaccination_record, :discarded, patient:, session:, programme:) + end + + it { should be(:none_yet) } + end + + context "with a consent refused" do + before { create(:consent, :refused, patient:, programme:) } + + it { should be(:refused) } + end + + context "when triaged as do not vaccinate" do + before { create(:triage, :do_not_vaccinate, patient:, programme:) } + + it { should be(:had_contraindications) } + end + + context "when not attending the session" do + before { create(:session_attendance, :absent, patient_session:) } + + it { should be(:absent_from_session) } + end + end end From e41aed760bfa23e4778db67580df7b43912bf2f9 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 25 Mar 2025 17:55:02 +0100 Subject: [PATCH 4/9] Update factories to create vaccination statuses This updates the factories to create instances of the new `PatientSession::VaccinationStatus` model to ensure the tests are running in representative database. --- spec/factories/patient_sessions.rb | 60 ++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/spec/factories/patient_sessions.rb b/spec/factories/patient_sessions.rb index f38d3b27cf..2352ae23dd 100644 --- a/spec/factories/patient_sessions.rb +++ b/spec/factories/patient_sessions.rb @@ -99,6 +99,16 @@ home_educated:, year_group: end + session_statuses do + session.programmes.map do |programme| + association( + :patient_session_session_status, + :refused, + patient_session: instance, + programme: + ) + end + end end trait :consent_refused_with_notes do @@ -112,6 +122,16 @@ home_educated:, year_group: end + session_statuses do + session.programmes.map do |programme| + association( + :patient_session_session_status, + :refused, + patient_session: instance, + programme: + ) + end + end end trait :consent_not_provided do @@ -179,6 +199,16 @@ home_educated:, year_group: end + session_statuses do + session.programmes.map do |programme| + association( + :patient_session_session_status, + :had_contraindications, + patient_session: instance, + programme: + ) + end + end end trait :triaged_kept_in_triage do @@ -234,6 +264,16 @@ home_educated:, year_group: end + session_statuses do + session.programmes.map do |programme| + association( + :patient_session_session_status, + :unwell, + patient_session: instance, + programme: + ) + end + end after(:create) do |patient_session, evaluator| patient_session.session.programmes.each do |programme| @@ -267,6 +307,16 @@ home_educated:, year_group: end + session_statuses do + session.programmes.map do |programme| + association( + :patient_session_session_status, + :unwell, + patient_session: instance, + programme: + ) + end + end after(:create) do |patient_session, evaluator| patient_session.session.programmes.each do |programme| @@ -300,6 +350,16 @@ home_educated:, year_group: end + session_statuses do + session.programmes.map do |programme| + association( + :patient_session_session_status, + :vaccinated, + patient_session: instance, + programme: + ) + end + end after(:create) do |patient_session, evaluator| patient_session.session.programmes.each do |programme| From b79e78746bc8bc53425fdbf4582c0e0bce7ee3ff Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 25 Mar 2025 18:48:58 +0100 Subject: [PATCH 5/9] Use PatientSession::VaccinationStatus This updates the various parts of the code that relied on the `SessionOutcome` class to determine the consent status of a patient to instead use the new `PatientSession::VaccinationStatus` model with the status cached. --- ...nt_session_search_result_card_component.rb | 14 +++---- .../app_programme_session_table_component.rb | 22 +++++------ .../app_session_details_summary_component.rb | 6 +-- .../session_attendances_controller.rb | 2 + app/controllers/session_dates_controller.rb | 2 + .../sessions/outcome_controller.rb | 23 +++-------- .../sessions/register_controller.rb | 6 ++- .../vaccination_records_controller.rb | 2 + app/forms/search_form.rb | 2 +- .../patient_session_status_concern.rb | 3 +- app/models/patient_session.rb | 39 ++++++++++++------- config/locales/status.en.yml | 12 +++--- ..._programme_session_table_component_spec.rb | 11 +++++- .../delete_vaccination_record_spec.rb | 7 ++++ spec/forms/search_form_spec.rb | 9 +++-- 15 files changed, 90 insertions(+), 70 deletions(-) diff --git a/app/components/app_patient_session_search_result_card_component.rb b/app/components/app_patient_session_search_result_card_component.rb index 4c64d17ef4..17066cf19d 100644 --- a/app/components/app_patient_session_search_result_card_component.rb +++ b/app/components/app_patient_session_search_result_card_component.rb @@ -101,15 +101,11 @@ def status_tag end render AppProgrammeStatusTagsComponent.new(statuses, outcome: :triage) else - outcome = patient_session.session_outcome - - # ensure status is calculated for each programme - patient_session.programmes.each { outcome.status[it] } - - render AppProgrammeStatusTagsComponent.new( - outcome.status, - outcome: context == :outcome ? :session : context - ) + statuses = + patient_session.programmes.index_with do |programme| + patient_session.session_status(programme:).status + end + render AppProgrammeStatusTagsComponent.new(statuses, outcome: :session) end end end diff --git a/app/components/app_programme_session_table_component.rb b/app/components/app_programme_session_table_component.rb index 1582791609..ec4ae35bac 100644 --- a/app/components/app_programme_session_table_component.rb +++ b/app/components/app_programme_session_table_component.rb @@ -16,17 +16,6 @@ def cohort_count(session:) format_number(session.patient_sessions.count) end - def number_stat(session:) - format_number(session.patient_sessions.select { yield it }.length) - end - - def percentage_stat(session:) - format_percentage( - session.patient_sessions.select { yield it }.length, - session.patient_sessions.count - ) - end - def no_response_scope(session:) session.patient_sessions.has_consent_status(:no_response, programme:) end @@ -48,12 +37,19 @@ def triage_needed_count(session:) ) end + def vaccinated_scope(session:) + session.patient_sessions.has_session_status(:vaccinated, programme:) + end + def vaccinated_count(session:) - number_stat(session:) { it.session_outcome.vaccinated?(programme) } + format_number(vaccinated_scope(session:).count) end def vaccinated_percentage(session:) - percentage_stat(session:) { it.session_outcome.vaccinated?(programme) } + format_percentage( + vaccinated_scope(session:).count, + session.patient_sessions.count + ) end def format_number(count) = count.to_s diff --git a/app/components/app_session_details_summary_component.rb b/app/components/app_session_details_summary_component.rb index ed6296a02d..5f8f7bad99 100644 --- a/app/components/app_session_details_summary_component.rb +++ b/app/components/app_session_details_summary_component.rb @@ -19,7 +19,7 @@ def call delegate :programmes, to: :session def cohort_row - count = patient_sessions.length + count = patient_sessions.count href = new_draft_class_import_path(session) { @@ -57,7 +57,7 @@ def vaccinated_row texts = session.programmes.map do |programme| count = - patient_sessions.count { it.session_outcome.vaccinated?(programme) } + patient_sessions.has_session_status(:vaccinated, programme:).count "#{I18n.t("vaccinations_given", count:)} for #{programme.name}" end @@ -66,7 +66,7 @@ def vaccinated_row session_outcome_path( session, search_form: { - session_status: PatientSession::SessionOutcome::VACCINATED + session_status: "vaccinated" } ) diff --git a/app/controllers/session_attendances_controller.rb b/app/controllers/session_attendances_controller.rb index fd5ff879f7..47f181a9e6 100644 --- a/app/controllers/session_attendances_controller.rb +++ b/app/controllers/session_attendances_controller.rb @@ -21,6 +21,8 @@ def update @session_attendance.save! end => success + StatusUpdater.call(patient: @patient) + if success name = @patient.full_name diff --git a/app/controllers/session_dates_controller.rb b/app/controllers/session_dates_controller.rb index 8efe5876de..9091f99227 100644 --- a/app/controllers/session_dates_controller.rb +++ b/app/controllers/session_dates_controller.rb @@ -24,6 +24,8 @@ def update @session.save! end + StatusUpdater.call(session: @session) + if params.include?(:add_another) @session.session_dates.build render :show diff --git a/app/controllers/sessions/outcome_controller.rb b/app/controllers/sessions/outcome_controller.rb index 500747e655..572bab30a0 100644 --- a/app/controllers/sessions/outcome_controller.rb +++ b/app/controllers/sessions/outcome_controller.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "pagy/extras/array" - class Sessions::OutcomeController < ApplicationController include Pagy::Backend include SearchFormConcern @@ -12,28 +10,19 @@ class Sessions::OutcomeController < ApplicationController layout "full" def show - @statuses = PatientSession::SessionOutcome::STATUSES + @statuses = PatientSession::SessionStatus.statuses.keys + @programmes = @session.programmes scope = - @session.patient_sessions.preload_for_status.in_programmes( - @session.programmes - ) - - patient_sessions = @form.apply(scope) + @session.patient_sessions.preload_for_status.in_programmes(@programmes) - if patient_sessions.is_a?(Array) - @pagy, @patient_sessions = pagy_array(patient_sessions) - else - @pagy, @patient_sessions = pagy(patient_sessions) - end + patient_sessions = @form.apply(scope, programme: @programmes) + @pagy, @patient_sessions = pagy(patient_sessions) end private def set_session - @session = - policy_scope(Session).includes(:programmes).find_by!( - slug: params[:session_slug] - ) + @session = policy_scope(Session).find_by!(slug: params[:session_slug]) end end diff --git a/app/controllers/sessions/register_controller.rb b/app/controllers/sessions/register_controller.rb index fa8270a40c..90668e9aba 100644 --- a/app/controllers/sessions/register_controller.rb +++ b/app/controllers/sessions/register_controller.rb @@ -31,7 +31,11 @@ def show def create session_attendance = authorize @patient_session.register_outcome.latest - session_attendance.update!(attending: params[:status] == "present") + + ActiveRecord::Base.transaction do + session_attendance.update!(attending: params[:status] == "present") + StatusUpdater.call(patient: @patient_session.patient) + end name = @patient_session.patient.full_name diff --git a/app/controllers/vaccination_records_controller.rb b/app/controllers/vaccination_records_controller.rb index 91ea9d4b4a..02bdd5e8af 100644 --- a/app/controllers/vaccination_records_controller.rb +++ b/app/controllers/vaccination_records_controller.rb @@ -35,6 +35,8 @@ def destroy @vaccination_record.discard! + StatusUpdater.call(patient: @vaccination_record.patient) + if @vaccination_record.confirmation_sent? send_vaccination_deletion(@vaccination_record) end diff --git a/app/forms/search_form.rb b/app/forms/search_form.rb index 57a4bf019f..648cbc1046 100644 --- a/app/forms/search_form.rb +++ b/app/forms/search_form.rb @@ -53,7 +53,7 @@ def apply(scope, programme: nil) end if (status = session_status&.to_sym).present? - scope = scope.select { it.session_outcome.status.values.include?(status) } + scope = scope.has_session_status(status, programme:) end if (status = register_status&.to_sym).present? diff --git a/app/models/concerns/patient_session_status_concern.rb b/app/models/concerns/patient_session_status_concern.rb index 89235d56c8..944fdf112c 100644 --- a/app/models/concerns/patient_session_status_concern.rb +++ b/app/models/concerns/patient_session_status_concern.rb @@ -32,7 +32,8 @@ def status(programme:) "consent_refused" elsif patient.triage_status(programme:).do_not_vaccinate? "triaged_do_not_vaccinate" - elsif session_outcome.not_vaccinated?(programme) + elsif !session_status(programme:).none_yet? && + !session_status(programme:).vaccinated? "unable_to_vaccinate" elsif patient.consent_status(programme:).given? && patient.triage_status(programme:).safe_to_vaccinate? diff --git a/app/models/patient_session.rb b/app/models/patient_session.rb index 6a055cebe4..0aeac82aad 100644 --- a/app/models/patient_session.rb +++ b/app/models/patient_session.rb @@ -69,13 +69,9 @@ class PatientSession < ApplicationRecord scope :preload_for_status, -> do eager_load(:patient).preload( - session_attendances: :session_date, - patient: %i[ - consent_statuses - triage_statuses - vaccination_records - vaccination_statuses - ], + :session_attendances, + :session_statuses, + patient: %i[consent_statuses triage_statuses vaccination_statuses], session: :programmes ) end @@ -120,6 +116,17 @@ class PatientSession < ApplicationRecord ) end + scope :has_session_status, + ->(status, programme:) do + where( + PatientSession::SessionStatus + .where("patient_session_id = patient_sessions.id") + .where(status:, programme:) + .arel + .exists + ) + end + scope :has_triage_status, ->(status, programme:) do where( @@ -154,12 +161,13 @@ def gillick_assessment(programme) .max_by(&:created_at) end - def register_outcome - @register_outcome ||= PatientSession::RegisterOutcome.new(self) + def session_status(programme:) + session_statuses.find { it.programme_id == programme.id } || + session_statuses.build(programme:) end - def session_outcome - @session_outcome ||= PatientSession::SessionOutcome.new(self) + def register_outcome + @register_outcome ||= PatientSession::RegisterOutcome.new(self) end def ready_for_vaccinator?(programme: nil) @@ -189,10 +197,13 @@ def next_activity(programme:) def outstanding_programmes # If this patient hasn't been seen yet by a nurse for any of the programmes, # we don't want to show the banner. - return [] if programmes.all? { session_outcome.none_yet?(it) } + all_programmes_none_yet = + programmes.all? { |programme| session_status(programme:).none_yet? } + + return [] if all_programmes_none_yet - programmes.select do - ready_for_vaccinator?(programme: it) && session_outcome.none_yet?(it) + programmes.select do |programme| + session_status(programme:).none_yet? && ready_for_vaccinator?(programme:) end end end diff --git a/config/locales/status.en.yml b/config/locales/status.en.yml index b854d0695e..bf9b5b12c2 100644 --- a/config/locales/status.en.yml +++ b/config/locales/status.en.yml @@ -39,21 +39,21 @@ en: label: absent_from_school: Absent from school absent_from_session: Absent from session - administered: Vaccinated already_had: Already had vaccine - contraindications: Had contraindications + had_contraindications: Had contraindications none_yet: No outcome yet - not_well: Unwell refused: Refused vaccine + unwell: Unwell + vaccinated: Vaccinated colour: absent_from_school: dark-orange absent_from_session: dark-orange - administered: green already_had: green - contraindications: red + had_contraindications: red none_yet: white - not_well: dark-orange refused: red + unwell: dark-orange + vaccinated: green programme: label: could_not_vaccinate: Could not vaccinate diff --git a/spec/components/app_programme_session_table_component_spec.rb b/spec/components/app_programme_session_table_component_spec.rb index e927a28848..f27673a318 100644 --- a/spec/components/app_programme_session_table_component_spec.rb +++ b/spec/components/app_programme_session_table_component_spec.rb @@ -17,9 +17,16 @@ create_list(:patient_session, 4, :consent_no_response, session:) create(:patient_consent_status, :given, programme:, patient:) - create(:vaccination_record, programme:, patient:, session:) - sessions.each { _1.strict_loading!(false) } + patient_session = + patient.patient_sessions.includes(session: :session_dates).first + + create( + :patient_session_session_status, + :vaccinated, + patient_session:, + programme: + ) end it { should have_content("3 sessions") } diff --git a/spec/features/delete_vaccination_record_spec.rb b/spec/features/delete_vaccination_record_spec.rb index bd27c18f45..6fc1af758c 100644 --- a/spec/features/delete_vaccination_record_spec.rb +++ b/spec/features/delete_vaccination_record_spec.rb @@ -137,6 +137,13 @@ def and_an_administered_vaccination_record_exists session: @session, batch: ) + + create( + :patient_session_session_status, + :vaccinated, + patient_session: @patient_session, + programme: @programme + ) end def and_a_confirmation_email_has_been_sent diff --git a/spec/forms/search_form_spec.rb b/spec/forms/search_form_spec.rb index 505f2b53cd..68f3569015 100644 --- a/spec/forms/search_form_spec.rb +++ b/spec/forms/search_form_spec.rb @@ -149,13 +149,16 @@ let(:programme_status) { nil } let(:q) { nil } let(:register_status) { nil } - let(:session_status) { "administered" } + let(:session_status) { "vaccinated" } let(:triage_status) { nil } let(:year_groups) { nil } + let(:programme) { create(:programme) } + it "filters on session status" do - patient_session = create(:patient_session, :vaccinated) - expect(form.apply(scope)).to include(patient_session) + patient_session = + create(:patient_session, :vaccinated, programmes: [programme]) + expect(form.apply(scope, programme:)).to include(patient_session) end end From 72ec0b732ae8a6dd6190d013260a78b9063e27ca Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 25 Mar 2025 19:33:13 +0100 Subject: [PATCH 6/9] Remove PatientSession::SessionOutcome This can be safely removed as it's no longer being used and has been replaced with the `PatientSession::VaccinationStatus` model. --- app/models/patient_session/session_outcome.rb | 78 ------------ .../patient_session/session_outcome_spec.rb | 116 ------------------ 2 files changed, 194 deletions(-) delete mode 100644 app/models/patient_session/session_outcome.rb delete mode 100644 spec/models/patient_session/session_outcome_spec.rb diff --git a/app/models/patient_session/session_outcome.rb b/app/models/patient_session/session_outcome.rb deleted file mode 100644 index 1ac102c198..0000000000 --- a/app/models/patient_session/session_outcome.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -class PatientSession::SessionOutcome - def initialize(patient_session) - @patient_session = patient_session - end - - STATUSES = [ - VACCINATED = :administered, - ALREADY_HAD = :already_had, - HAD_CONTRAINDICATIONS = :contraindications, - REFUSED = :refused, - ABSENT_FROM_SCHOOL = :absent_from_school, - ABSENT_FROM_SESSION = :absent_from_session, - UNWELL = :not_well, - NONE_YET = :none_yet - ].freeze - - def vaccinated?(programme) = status[programme] == VACCINATED - - def already_had?(programme) = status[programme] == ALREADY_HAD - - def not_vaccinated?(programme) = - status[programme] != VACCINATED && status[programme] != NONE_YET - - def none_yet?(programme) = status[programme] == NONE_YET - - def status - @status ||= programmes.index_with { programme_status(it) } - end - - def all - @all ||= - Hash.new do |hash, programme| - hash[programme] = all_by_programme_id.fetch(programme.id, []) - end - end - - def latest - @latest ||= - Hash.new do |hash, programme| - hash[programme] = all[programme].max_by(&:created_at) - end - end - - private - - attr_reader :patient_session - - delegate :patient, - :programmes, - :register_outcome, - :session, - to: :patient_session - - def programme_status(programme) - if (vaccination_record = latest[programme]) - vaccination_record.outcome.to_sym - elsif patient.consent_status(programme:).refused? - REFUSED - elsif patient.triage_status(programme:).do_not_vaccinate? - HAD_CONTRAINDICATIONS - elsif register_outcome.not_attending? - ABSENT_FROM_SESSION - else - NONE_YET - end - end - - def all_by_programme_id - @all_by_programme_id ||= - patient - .vaccination_records - .reject(&:discarded?) - .select { it.session_id == session.id } - .group_by(&:programme_id) - end -end diff --git a/spec/models/patient_session/session_outcome_spec.rb b/spec/models/patient_session/session_outcome_spec.rb deleted file mode 100644 index 2f34c89f11..0000000000 --- a/spec/models/patient_session/session_outcome_spec.rb +++ /dev/null @@ -1,116 +0,0 @@ -# frozen_string_literal: true - -describe PatientSession::SessionOutcome do - subject(:instance) { described_class.new(patient_session) } - - let(:programme) { create(:programme, :hpv) } - let(:patient) { create(:patient, year_group: 8) } - let(:session) { create(:session, programmes: [programme]) } - let(:patient_session) { create(:patient_session, patient:, session:) } - - before { patient.strict_loading!(false) } - - describe "#status" do - subject(:status) { instance.status[programme] } - - context "with no vaccination record" do - it { should be(described_class::NONE_YET) } - end - - context "with a vaccination administered" do - before { create(:vaccination_record, patient:, session:, programme:) } - - it { should be(described_class::VACCINATED) } - end - - context "with a vaccination not administered" do - before do - create( - :vaccination_record, - :not_administered, - patient:, - session:, - programme: - ) - end - - it { should be(described_class::UNWELL) } - end - - context "with a discarded vaccination administered" do - before do - create(:vaccination_record, :discarded, patient:, session:, programme:) - end - - it { should be(described_class::NONE_YET) } - end - - context "with a consent refused" do - before { create(:patient_consent_status, :refused, patient:, programme:) } - - it { should be(described_class::REFUSED) } - end - - context "when triaged as do not vaccinate" do - before do - create(:patient_triage_status, :do_not_vaccinate, patient:, programme:) - end - - it { should be(described_class::HAD_CONTRAINDICATIONS) } - end - - context "when not attending the session" do - before { create(:session_attendance, :absent, patient_session:) } - - it { should be(described_class::ABSENT_FROM_SESSION) } - end - end - - describe "#all" do - subject(:all) { instance.all[programme] } - - let(:later_vaccination_record) do - create(:vaccination_record, patient:, session:, programme:) - end - let(:earlier_vaccination_record) do - create( - :vaccination_record, - patient:, - session:, - programme:, - created_at: 1.day.ago - ) - end - - it { should eq([earlier_vaccination_record, later_vaccination_record]) } - end - - describe "#latest" do - subject(:latest) { instance.latest[programme] } - - let(:later_vaccination_record) do - create( - :vaccination_record, - created_at: 1.day.ago, - patient:, - session:, - programme: - ) - end - - before do - create( - :vaccination_record, - created_at: 2.days.ago, - patient:, - session:, - programme: - ) - - # should not be returned as discarded even if more recent - create(:vaccination_record, :discarded, patient:, session:, programme:) - end - - it { should eq(later_vaccination_record) } - end -end From 003d1d4b0225a9f768d1d5d46156bf88128601f9 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 26 Mar 2025 11:51:11 +0100 Subject: [PATCH 7/9] Add StatusUpdaterJob This adds a job which handles the process of updating the status of patients in the background so we can run it on a schedule or for larger sessions. --- app/jobs/status_updater_job.rb | 13 +++++++++++++ spec/jobs/status_updater_job_spec.rb | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 app/jobs/status_updater_job.rb create mode 100644 spec/jobs/status_updater_job_spec.rb diff --git a/app/jobs/status_updater_job.rb b/app/jobs/status_updater_job.rb new file mode 100644 index 0000000000..5e29cd65ef --- /dev/null +++ b/app/jobs/status_updater_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class StatusUpdaterJob < NotifyDeliveryJob + include GoodJob::ActiveJobExtensions::Concurrency + + queue_as :statuses + + good_job_control_concurrency_with perform_limit: 1 + + def perform(patient: nil, session: nil) + StatusUpdater.call(patient: patient, session: session) + end +end diff --git a/spec/jobs/status_updater_job_spec.rb b/spec/jobs/status_updater_job_spec.rb new file mode 100644 index 0000000000..137788805d --- /dev/null +++ b/spec/jobs/status_updater_job_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +describe StatusUpdaterJob do + describe "#perform_now" do + subject(:perform_now) { described_class.perform_now(patient:, session:) } + + let(:patient) { build(:patient) } + let(:session) { build(:session) } + + it "calls the service class" do + expect(StatusUpdater).to receive(:call).with( + patient: patient, + session: session + ) + perform_now + end + end +end From 852695f726fa997e35fc8a284aa7e179144bc667 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 26 Mar 2025 11:52:53 +0100 Subject: [PATCH 8/9] Update session statuses in background Often the session can have lots of patients in and this can be slow. To ensure the user doesn't see this slowdown, we can queue the job to run in the background and the statuses will be updated shortly. --- app/controllers/session_dates_controller.rb | 2 +- app/forms/session_programmes_form.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/session_dates_controller.rb b/app/controllers/session_dates_controller.rb index 9091f99227..5050c6a085 100644 --- a/app/controllers/session_dates_controller.rb +++ b/app/controllers/session_dates_controller.rb @@ -24,7 +24,7 @@ def update @session.save! end - StatusUpdater.call(session: @session) + StatusUpdaterJob.perform_later(session: @session) if params.include?(:add_another) @session.session_dates.build diff --git a/app/forms/session_programmes_form.rb b/app/forms/session_programmes_form.rb index cc0f45b9d7..1eb25465a7 100644 --- a/app/forms/session_programmes_form.rb +++ b/app/forms/session_programmes_form.rb @@ -15,7 +15,7 @@ def save return false if invalid? session.programme_ids = programme_ids - StatusUpdater.call(session:) + StatusUpdaterJob.perform_later(session:) true end From 4fe227ff9899a7b42449aba5fb11602f1caa6c69 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 26 Mar 2025 13:28:03 +0100 Subject: [PATCH 9/9] Update patient statuses overnight This is necessary as the registration status of a patient is tied to the current day and needs to reset the next day when there may no longer be a session date on that day. --- config/environments/production.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config/environments/production.rb b/config/environments/production.rb index 78f29fece4..ec3f690ed7 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -135,6 +135,11 @@ class: "RemoveImportCSVJob", description: "Remove CSV data from old cohort and immunisation imports" }, + status_updater: { + cron: "every day at 3am", + class: "StatusUpdaterJob", + description: "Updates the status of all patients" + }, trim_active_record_sessions: { cron: "every day at 2am", class: "TrimActiveRecordSessionsJob",