From 6cc41645fab7b1ad91bfc6627ae572f9aef0306f Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 21 Aug 2025 17:47:59 +0100 Subject: [PATCH 01/27] Add `sessions configure` command This adds a new command to the CLI which allows ops to bulk enable or disable delegation options or registration requirement for all the sessions for a particular team. This is necessary to avoid needing the teams to manually edit these options for each session (when they may have hundreds). Jira-Issue: MAV-1668 Jira-Issue: MAV-1787 --- app/lib/mavis_cli.rb | 1 + app/lib/mavis_cli/sessions/configure.rb | 61 +++++++++++ spec/features/cli_sessions_configure_spec.rb | 104 +++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 app/lib/mavis_cli/sessions/configure.rb create mode 100644 spec/features/cli_sessions_configure_spec.rb diff --git a/app/lib/mavis_cli.rb b/app/lib/mavis_cli.rb index a7ef1acb53..05a2a33850 100644 --- a/app/lib/mavis_cli.rb +++ b/app/lib/mavis_cli.rb @@ -43,6 +43,7 @@ def self.progress_bar(total) require_relative "mavis_cli/schools/create" require_relative "mavis_cli/schools/move_patients" require_relative "mavis_cli/schools/remove_programme_year_group" +require_relative "mavis_cli/sessions/configure" require_relative "mavis_cli/stats/consents_by_school" require_relative "mavis_cli/stats/organisations" require_relative "mavis_cli/stats/vaccinations" diff --git a/app/lib/mavis_cli/sessions/configure.rb b/app/lib/mavis_cli/sessions/configure.rb new file mode 100644 index 0000000000..f330c3d0e1 --- /dev/null +++ b/app/lib/mavis_cli/sessions/configure.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module MavisCLI + module Sessions + class Configure < Dry::CLI::Command + desc "Configure options in bulk" + + argument :workgroup, required: true, desc: "Workgroup of the team" + argument :programme_type, + required: true, + desc: "Find sessions that administer this programme" + + option :requires_registration, + type: :boolean, + desc: "Whether the session requires registration" + option :psd_enabled, + type: :boolean, + desc: "Use patient specific direction (PSD)" + option :national_protocol_enabled, + type: :boolean, + desc: "Use national protocol" + + def call( + workgroup:, + programme_type:, + requires_registration: nil, + psd_enabled: nil, + national_protocol_enabled: nil + ) + MavisCLI.load_rails + + team = Team.find_by!(workgroup:) + programme = Programme.find_by!(type: programme_type) + + attributes = { + requires_registration:, + psd_enabled:, + national_protocol_enabled: + }.compact + + team + .sessions + .includes(:location) + .where(academic_year: AcademicYear.pending) + .has_programmes([programme]) + .find_each do |session| + session.assign_attributes(attributes) + + if session.changed? + session.save! + puts "Updated #{session.slug}: #{session.location.name}" + end + end + end + end + end + + register "sessions" do |prefix| + prefix.register "configure", Sessions::Configure + end +end diff --git a/spec/features/cli_sessions_configure_spec.rb b/spec/features/cli_sessions_configure_spec.rb new file mode 100644 index 0000000000..c39c6d5819 --- /dev/null +++ b/spec/features/cli_sessions_configure_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require_relative "../../app/lib/mavis_cli" + +describe "mavis sessions delegation" do + context "with valid arguments" do + it "runs successfully" do + given_a_team_exists + and_sessions_exist + + when_i_run_the_command + then_the_team_sessions_are_updated + and_the_non_team_sessions_are_left + end + end + + private + + def command + Dry::CLI.new(MavisCLI).call( + arguments: %w[ + sessions + configure + team + flu + --psd-enabled + --national-protocol-enabled + --no-requires-registration + ] + ) + end + + def given_a_team_exists + @programmes = [create(:programme, :hpv), create(:programme, :flu)] + @team = create(:team, workgroup: "team", programmes: @programmes) + end + + def and_sessions_exist + academic_year = AcademicYear.pending + + @team_hpv_session = + create( + :session, + :unscheduled, + team: @team, + programmes: [@programmes.first], + academic_year: + ) + @non_team_hpv_session = + create( + :session, + :unscheduled, + programmes: [@programmes.first], + academic_year: + ) + @team_flu_session = + create( + :session, + :unscheduled, + team: @team, + programmes: [@programmes.second], + academic_year: + ) + @non_team_flu_session = + create( + :session, + :unscheduled, + programmes: [@programmes.second], + academic_year: + ) + end + + def when_i_run_the_command + @output = capture_output { command } + end + + def then_the_team_sessions_are_updated + expect(@team_flu_session.reload).to have_attributes( + psd_enabled: true, + national_protocol_enabled: true, + requires_registration: false + ) + + expect(@team_hpv_session.reload).to have_attributes( + psd_enabled: false, + national_protocol_enabled: false, + requires_registration: true + ) + end + + def and_the_non_team_sessions_are_left + expect(@non_team_flu_session.reload).to have_attributes( + psd_enabled: false, + national_protocol_enabled: false, + requires_registration: true + ) + + expect(@non_team_hpv_session.reload).to have_attributes( + psd_enabled: false, + national_protocol_enabled: false, + requires_registration: true + ) + end +end From 0d22cc21aadae20c2f8a703d014be7e809828e5b Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 21 Aug 2025 16:37:40 +0100 Subject: [PATCH 02/27] Refactor session edit routes This refactors the routes and controllers related to the session edit page to be a more conventional Rails style. Jira-Issue: MAV-1357 --- app/controllers/session_dates_controller.rb | 2 +- app/controllers/sessions/edit_controller.rb | 26 +++++------ app/controllers/sessions_controller.rb | 3 -- app/views/session_dates/show.html.erb | 4 +- app/views/sessions/edit/programmes.html.erb | 4 +- .../edit/send_consent_requests_at.html.erb | 4 +- .../edit/send_invitations_at.html.erb | 4 +- .../{edit.html.erb => edit/show.html.erb} | 8 ++-- .../weeks_before_consent_reminders.html.erb | 4 +- app/views/sessions/show.html.erb | 4 +- config/routes.rb | 45 +++++++------------ 11 files changed, 45 insertions(+), 63 deletions(-) rename app/views/sessions/{edit.html.erb => edit/show.html.erb} (83%) diff --git a/app/controllers/session_dates_controller.rb b/app/controllers/session_dates_controller.rb index 1c05a0f1ff..276a0abefb 100644 --- a/app/controllers/session_dates_controller.rb +++ b/app/controllers/session_dates_controller.rb @@ -34,7 +34,7 @@ def update if any_destroyed? session_dates_path(@session) else - edit_session_path(@session) + session_edit_path(@session) end ) end diff --git a/app/controllers/sessions/edit_controller.rb b/app/controllers/sessions/edit_controller.rb index a3e41e4277..3c7a60ba62 100644 --- a/app/controllers/sessions/edit_controller.rb +++ b/app/controllers/sessions/edit_controller.rb @@ -3,28 +3,28 @@ class Sessions::EditController < ApplicationController before_action :set_session - def edit_programmes + def show + end + + def programmes @form = SessionProgrammesForm.new( session: @session, programme_ids: @session.programme_ids ) - - render :programmes end def update_programmes @form = SessionProgrammesForm.new(session: @session, **programmes_params) if @form.save - redirect_to edit_session_path(@session) + redirect_to session_edit_path(@session) else render :programmes, status: :unprocessable_content end end - def edit_send_consent_requests_at - render :send_consent_requests_at + def send_consent_requests_at end def update_send_consent_requests_at @@ -35,12 +35,11 @@ def update_send_consent_requests_at elsif !@session.update(send_consent_requests_at_params) render :send_consent_requests_at, status: :unprocessable_content else - redirect_to edit_session_path(@session) + redirect_to session_edit_path(@session) end end - def edit_send_invitations_at - render :send_invitations_at + def send_invitations_at end def update_send_invitations_at @@ -51,17 +50,16 @@ def update_send_invitations_at elsif !@session.update(send_invitations_at_params) render :send_invitations_at, status: :unprocessable_content else - redirect_to edit_session_path(@session) + redirect_to session_edit_path(@session) end end - def edit_weeks_before_consent_reminders - render :weeks_before_consent_reminders + def weeks_before_consent_reminders end def update_weeks_before_consent_reminders if @session.update(weeks_before_consent_reminders_params) - redirect_to edit_session_path(@session) + redirect_to session_edit_path(@session) else render :weeks_before_consent_reminders, status: :unprocessable_content end @@ -70,7 +68,7 @@ def update_weeks_before_consent_reminders private def set_session - @session = policy_scope(Session).find_by!(slug: params[:slug]) + @session = policy_scope(Session).find_by!(slug: params[:session_slug]) end def programmes_params diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 1152ea4091..0c2c07519e 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -51,9 +51,6 @@ def show end end - def edit - end - def import draft_import = DraftImport.new(request_session: session, current_user:) diff --git a/app/views/session_dates/show.html.erb b/app/views/session_dates/show.html.erb index 2e601b91f7..dac27db415 100644 --- a/app/views/session_dates/show.html.erb +++ b/app/views/session_dates/show.html.erb @@ -1,5 +1,5 @@ <% content_for :before_main do %> - <%= govuk_back_link(href: edit_session_path(@session)) %> + <%= govuk_back_link(href: session_edit_path(@session)) %> <% end %> <%= h1 "When will sessions be held?" %> @@ -42,6 +42,6 @@
<%= f.govuk_submit "Continue" %> - <%= govuk_link_to "Back", edit_session_path(@session) %> + <%= govuk_link_to "Back", session_edit_path(@session) %>
<% end %> diff --git a/app/views/sessions/edit/programmes.html.erb b/app/views/sessions/edit/programmes.html.erb index 2b9ce43a7c..91603c3f02 100644 --- a/app/views/sessions/edit/programmes.html.erb +++ b/app/views/sessions/edit/programmes.html.erb @@ -1,11 +1,11 @@ <% content_for :before_main do %> - <%= govuk_back_link(href: edit_session_path(@session)) %> + <%= govuk_back_link(href: session_edit_path(@session)) %> <% end %> <% legend = "Which programmes is this session part of?" %> <% content_for :page_title, legend %> -<%= form_with model: @form, url: edit_programmes_session_path(@session), method: :put do |f| %> +<%= form_with model: @form, url: programmes_session_edit_path(@session), method: :put do |f| %> <% content_for(:before_content) { f.govuk_error_summary } %> <%= f.govuk_collection_check_boxes :programme_ids, policy_scope(Programme), :id, :name, diff --git a/app/views/sessions/edit/send_consent_requests_at.html.erb b/app/views/sessions/edit/send_consent_requests_at.html.erb index e5eb88dd10..dc591990ba 100644 --- a/app/views/sessions/edit/send_consent_requests_at.html.erb +++ b/app/views/sessions/edit/send_consent_requests_at.html.erb @@ -1,11 +1,11 @@ <% content_for :before_main do %> - <%= govuk_back_link(href: edit_session_path(@session)) %> + <%= govuk_back_link(href: session_edit_path(@session)) %> <% end %> <% legend = "When should parents get a request to give consent?" %> <% content_for :page_title, legend %> -<%= form_with model: @session, url: edit_send_consent_requests_at_session_path(@session), method: :put do |f| %> +<%= form_with model: @session, url: send_consent_requests_at_session_edit_path(@session), method: :put do |f| %> <% content_for(:before_content) { f.govuk_error_summary } %> <%= f.govuk_date_field :send_consent_requests_at, diff --git a/app/views/sessions/edit/send_invitations_at.html.erb b/app/views/sessions/edit/send_invitations_at.html.erb index 7933b53f7e..bc0148a645 100644 --- a/app/views/sessions/edit/send_invitations_at.html.erb +++ b/app/views/sessions/edit/send_invitations_at.html.erb @@ -1,11 +1,11 @@ <% content_for :before_main do %> - <%= govuk_back_link(href: edit_session_path(@session)) %> + <%= govuk_back_link(href: session_edit_path(@session)) %> <% end %> <% legend = "When should parents get an invitation?" %> <% content_for :page_title, legend %> -<%= form_with model: @session, url: edit_send_invitations_at_session_path(@session), method: :put do |f| %> +<%= form_with model: @session, url: send_invitations_at_session_edit_path(@session), method: :put do |f| %> <% content_for(:before_content) { f.govuk_error_summary } %> <%= f.govuk_date_field :send_invitations_at, diff --git a/app/views/sessions/edit.html.erb b/app/views/sessions/edit/show.html.erb similarity index 83% rename from app/views/sessions/edit.html.erb rename to app/views/sessions/edit/show.html.erb index 65781c7425..200387edcb 100644 --- a/app/views/sessions/edit.html.erb +++ b/app/views/sessions/edit/show.html.erb @@ -14,7 +14,7 @@ summary_list.with_row do |row| row.with_key { "Programmes" } row.with_value { render AppProgrammeTagsComponent.new(@session.programmes) } - row.with_action(text: "Change", href: edit_programmes_session_path(@session), visually_hidden_text: "programmes") + row.with_action(text: "Change", href: programmes_session_edit_path(@session), visually_hidden_text: "programmes") end summary_list.with_row do |row| @@ -37,7 +37,7 @@ row.with_key { "Consent requests" } row.with_value { "Send on #{send_consent_requests_at.to_fs(:long_day_of_week)}" } if @session.can_change_notification_dates? - row.with_action(text: "Change", href: edit_send_consent_requests_at_session_path(@session), visually_hidden_text: "consent requests") + row.with_action(text: "Change", href: send_consent_requests_at_session_edit_path(@session), visually_hidden_text: "consent requests") end end end @@ -52,7 +52,7 @@ ], tag.br) end if @session.can_change_notification_dates? - row.with_action(text: "Change", href: edit_weeks_before_consent_reminders_session_path(@session), visually_hidden_text: "consent reminders") + row.with_action(text: "Change", href: weeks_before_consent_reminders_session_edit_path(@session), visually_hidden_text: "consent reminders") end end end @@ -62,7 +62,7 @@ row.with_key { "Invitations" } row.with_value { "Send on #{send_invitations_at.to_fs(:long_day_of_week)}" } if @session.can_change_notification_dates? - row.with_action(text: "Change", href: edit_send_invitations_at_session_path(@session), visually_hidden_text: "invitations") + row.with_action(text: "Change", href: send_invitations_at_session_edit_path(@session), visually_hidden_text: "invitations") end end end diff --git a/app/views/sessions/edit/weeks_before_consent_reminders.html.erb b/app/views/sessions/edit/weeks_before_consent_reminders.html.erb index f31f8d2caa..95d6ac52a8 100644 --- a/app/views/sessions/edit/weeks_before_consent_reminders.html.erb +++ b/app/views/sessions/edit/weeks_before_consent_reminders.html.erb @@ -1,11 +1,11 @@ <% content_for :before_main do %> - <%= govuk_back_link(href: edit_session_path(@session)) %> + <%= govuk_back_link(href: session_edit_path(@session)) %> <% end %> <% legend = "When should parents get a reminder to give consent?" %> <% content_for :page_title, legend %> -<%= form_with model: @session, url: edit_weeks_before_consent_reminders_session_path(@session), method: :put do |f| %> +<%= form_with model: @session, url: weeks_before_consent_reminders_session_edit_path(@session), method: :put do |f| %> <% content_for(:before_content) { f.govuk_error_summary } %> <%= f.govuk_text_field :weeks_before_consent_reminders, diff --git a/app/views/sessions/show.html.erb b/app/views/sessions/show.html.erb index 4f885c386f..a2e1792aaf 100644 --- a/app/views/sessions/show.html.erb +++ b/app/views/sessions/show.html.erb @@ -31,14 +31,14 @@ <% if @session.unscheduled? %> <% if policy(@session).edit? %> - <%= govuk_button_link_to "Schedule sessions", edit_session_path(@session), secondary: true %> + <%= govuk_button_link_to "Schedule sessions", session_edit_path(@session), secondary: true %> <% end %> <% else %> <%= render AppSessionActionsComponent.new(@session) %>
<% if policy(@session).edit? %> - <%= govuk_button_link_to "Edit session", edit_session_path(@session), secondary: true %> + <%= govuk_button_link_to "Edit session", session_edit_path(@session), secondary: true %> <% end %> <%= govuk_link_to "Record offline", session_path(@session, format: :xlsx) %> diff --git a/config/routes.rb b/config/routes.rb index 90b303a50b..b6fe8a71df 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -211,7 +211,7 @@ get "download", on: :member end - resources :sessions, only: %i[edit index show], param: :slug do + resources :sessions, only: %i[index show], param: :slug do resource :patients, only: :show, controller: "sessions/patients" resource :consent, only: :show, controller: "sessions/consent" resource :triage, only: :show, controller: "sessions/triage" @@ -225,6 +225,21 @@ post "batch/:programme_type/:vaccine_method", action: :update_batch end + resource :edit, only: :show, controller: "sessions/edit" do + get "programmes" + put "programmes", action: :update_programmes + + get "send-consent-requests-at" + put "send-consent-requests-at", action: :update_send_consent_requests_at + + get "send-invitations-at" + put "send-invitations-at", action: :update_send_invitations_at + + get "weeks-before-consent-reminders" + put "weeks-before-consent-reminders", + action: :update_weeks_before_consent_reminders + end + resource :invite_to_clinic, path: "invite-to-clinic", only: %i[edit update], @@ -233,34 +248,6 @@ member do get "import" - get "edit/programmes", - controller: "sessions/edit", - action: "edit_programmes" - put "edit/programmes", - controller: "sessions/edit", - action: "update_programmes" - - get "edit/send-consent-requests-at", - controller: "sessions/edit", - action: "edit_send_consent_requests_at" - put "edit/send-consent-requests-at", - controller: "sessions/edit", - action: "update_send_consent_requests_at" - - get "edit/send-invitations-at", - controller: "sessions/edit", - action: "edit_send_invitations_at" - put "edit/send-invitations-at", - controller: "sessions/edit", - action: "update_send_invitations_at" - - get "edit/weeks-before-consent-reminders", - controller: "sessions/edit", - action: "edit_weeks_before_consent_reminders" - put "edit/weeks-before-consent-reminders", - controller: "sessions/edit", - action: "update_weeks_before_consent_reminders" - constraints -> { Flipper.enabled?(:dev_tools) } do put "make-in-progress", to: "sessions#make_in_progress" end From 03f08c03bb404ee94669838137eab76ba42234ac Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 27 Aug 2025 08:19:10 +0100 Subject: [PATCH 03/27] Restrict who can edit session details This restricts who can edit the details of a session to nurses and admins, ensuring that healthcare assistants can't enable protocols that would allow them to administer vaccines. Jira-Issue: MAV-1357 --- app/controllers/sessions/edit_controller.rb | 23 +++++++++++++ app/policies/session_policy.rb | 4 +++ spec/factories/users.rb | 2 +- spec/policies/session_policy_spec.rb | 36 +++++++++++++++------ 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/app/controllers/sessions/edit_controller.rb b/app/controllers/sessions/edit_controller.rb index 3c7a60ba62..9b456e7446 100644 --- a/app/controllers/sessions/edit_controller.rb +++ b/app/controllers/sessions/edit_controller.rb @@ -3,6 +3,21 @@ class Sessions::EditController < ApplicationController before_action :set_session + before_action :authorize_session_edit, + except: %i[ + update_programmes + update_send_consent_requests_at + update_send_invitations_at + update_weeks_before_consent_reminders + ] + before_action :authorize_session_update, + only: %i[ + update_programmes + update_send_consent_requests_at + update_send_invitations_at + update_weeks_before_consent_reminders + ] + def show end @@ -71,6 +86,14 @@ def set_session @session = policy_scope(Session).find_by!(slug: params[:session_slug]) end + def authorize_session_edit + authorize @session, :edit? + end + + def authorize_session_update + authorize @session, :update? + end + def programmes_params params.expect(session_programmes_form: { programme_ids: [] }) end diff --git a/app/policies/session_policy.rb b/app/policies/session_policy.rb index 46ef0b9caa..3c2d9e2866 100644 --- a/app/policies/session_policy.rb +++ b/app/policies/session_policy.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class SessionPolicy < ApplicationPolicy + def update? + user.is_nurse? || user.is_admin? + end + def import? = show? def make_in_progress? = edit? diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 6874af3a8a..07f7b46942 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -93,7 +93,7 @@ trait :healthcare_assistant do sequence(:email) { |n| "healthcare-assistant-#{n}@example.com" } - role_code { CIS2Info::ADMIN_ROLE } + role_code { nil } activity_codes do [CIS2Info::PERSONAL_MEDICATION_ADMINISTRATION_ACTIVITY_CODE] end diff --git a/spec/policies/session_policy_spec.rb b/spec/policies/session_policy_spec.rb index a8454738e6..83c9054f9b 100644 --- a/spec/policies/session_policy_spec.rb +++ b/spec/policies/session_policy_spec.rb @@ -35,6 +35,22 @@ it { should be(true) } end end + + context "with a healthcare assistant" do + let(:user) { create(:healthcare_assistant) } + + context "with a scheduled session" do + let(:session) { create(:session, :scheduled) } + + it { should be(false) } + end + + context "with an unscheduled session" do + let(:session) { create(:session, :unscheduled) } + + it { should be(false) } + end + end end describe "#edit?" do @@ -49,17 +65,19 @@ include_examples "edit/update session" end - describe "Scope#resolve" do - subject { SessionPolicy::Scope.new(user, Session).resolve } + describe SessionPolicy::Scope do + describe "#resolve" do + subject { described_class.new(user, Session).resolve } - let(:programmes) { [create(:programme)] } - let(:team) { create(:team, programmes:) } - let(:user) { create(:user, team:) } + let(:programmes) { [create(:programme)] } + let(:team) { create(:team, programmes:) } + let(:user) { create(:user, team:) } - let(:users_teams_session) { create(:session, team:, programmes:) } - let(:another_teams_session) { create(:session, programmes:) } + let(:users_teams_session) { create(:session, team:, programmes:) } + let(:another_teams_session) { create(:session, programmes:) } - it { should include(users_teams_session) } - it { should_not include(another_teams_session) } + it { should include(users_teams_session) } + it { should_not include(another_teams_session) } + end end end From 1ba204e79286df58accae9e2063a8d70190403d5 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 21 Aug 2025 17:00:52 +0100 Subject: [PATCH 04/27] Add ability to edit session registration This adds a new page allowing users to edit whether or not a session should register attendance, this matches the latest designs in the prototype. Jira-Issue: MAV-1357 --- app/controllers/sessions/edit_controller.rb | 17 ++++++++++ .../edit/register_attendance.html.erb | 20 ++++++++++++ app/views/sessions/edit/show.html.erb | 6 ++++ config/routes.rb | 3 ++ spec/features/manage_attendance_spec.rb | 31 +++++++++++++++++-- 5 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 app/views/sessions/edit/register_attendance.html.erb diff --git a/app/controllers/sessions/edit_controller.rb b/app/controllers/sessions/edit_controller.rb index 9b456e7446..35c9af7145 100644 --- a/app/controllers/sessions/edit_controller.rb +++ b/app/controllers/sessions/edit_controller.rb @@ -9,6 +9,7 @@ class Sessions::EditController < ApplicationController update_send_consent_requests_at update_send_invitations_at update_weeks_before_consent_reminders + update_register_attendance ] before_action :authorize_session_update, only: %i[ @@ -16,6 +17,7 @@ class Sessions::EditController < ApplicationController update_send_consent_requests_at update_send_invitations_at update_weeks_before_consent_reminders + update_register_attendance ] def show @@ -80,6 +82,17 @@ def update_weeks_before_consent_reminders end end + def register_attendance + end + + def update_register_attendance + if @session.update(register_attendance_params) + redirect_to session_edit_path(@session) + else + render :register_attendance, status: :unprocessable_content + end + end + private def set_session @@ -127,4 +140,8 @@ def send_invitations_at_params def weeks_before_consent_reminders_params params.expect(session: :weeks_before_consent_reminders) end + + def register_attendance_params + params.expect(session: :requires_registration) + end end diff --git a/app/views/sessions/edit/register_attendance.html.erb b/app/views/sessions/edit/register_attendance.html.erb new file mode 100644 index 0000000000..6dafde2a8c --- /dev/null +++ b/app/views/sessions/edit/register_attendance.html.erb @@ -0,0 +1,20 @@ +<% content_for :before_main do %> + <%= govuk_back_link(href: session_edit_path(@session)) %> +<% end %> + +<% legend = "Do you want to register children’s attendance before recording vaccinations?" %> +<% content_for :page_title, legend %> + +<%= form_with model: @session, url: register_attendance_session_edit_path(@session), method: :put do |f| %> + <% content_for(:before_content) { f.govuk_error_summary } %> + + <%= f.govuk_radio_buttons_fieldset :requires_registration, + legend: { text: legend, size: "l", tag: "h1" }, + caption: { text: @session.location.name, size: "l" }, + link_errors: true do %> + <%= f.govuk_radio_button :requires_registration, "true", label: { text: "Yes" } %> + <%= f.govuk_radio_button :requires_registration, "false", label: { text: "No" } %> + <% end %> + + <%= f.govuk_submit "Continue" %> +<% end %> diff --git a/app/views/sessions/edit/show.html.erb b/app/views/sessions/edit/show.html.erb index 200387edcb..6d60634e38 100644 --- a/app/views/sessions/edit/show.html.erb +++ b/app/views/sessions/edit/show.html.erb @@ -66,6 +66,12 @@ end end end + + summary_list.with_row do |row| + row.with_key { "Register attendance" } + row.with_value { @session.requires_registration ? "Yes" : "No" } + row.with_action(text: "Change", href: register_attendance_session_edit_path(@session), visually_hidden_text: "register attendance") + end end %> <% end %> diff --git a/config/routes.rb b/config/routes.rb index b6fe8a71df..a173ebc6cd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -238,6 +238,9 @@ get "weeks-before-consent-reminders" put "weeks-before-consent-reminders", action: :update_weeks_before_consent_reminders + + get "register-attendance" + put "register-attendance", action: :update_register_attendance end resource :invite_to_clinic, diff --git a/spec/features/manage_attendance_spec.rb b/spec/features/manage_attendance_spec.rb index 419f2272e5..2abbb5bfcc 100644 --- a/spec/features/manage_attendance_spec.rb +++ b/spec/features/manage_attendance_spec.rb @@ -47,13 +47,30 @@ and_the_session_has_patients when_i_go_to_the_session - then_i_should_not_see_the_register_tab + then_i_do_not_see_the_register_tab when_i_go_to_the_session_patients and_i_go_to_a_patient then_i_should_not_see_link_to_update_attendance end + scenario "Turning off attendance" do + given_my_team_is_running_an_hpv_vaccination_programme + and_there_is_a_vaccination_session_today + and_the_session_has_patients + + when_i_go_to_the_session + and_i_click_on_the_register_tab + then_i_see_the_register_tab + + when_i_go_to_the_session + and_i_edit_the_session + and_i_turn_off_register_attendance + + when_i_go_to_the_session + then_i_do_not_see_the_register_tab + end + def given_my_team_is_running_an_hpv_vaccination_programme @programmes = [create(:programme, :hpv_all_vaccines)] @team = create(:team, :with_one_nurse, programmes: @programmes) @@ -103,7 +120,7 @@ def when_i_click_on_the_record_vaccinations_tab click_link "Record vaccinations" end - def then_i_should_not_see_the_register_tab + def then_i_do_not_see_the_register_tab expect(page).not_to have_content("Register") end @@ -204,4 +221,14 @@ def when_i_go_to_the_activity_log def then_i_see_the_attendance_event expect(page).to have_content("Attended session") end + + def and_i_edit_the_session + click_on "Edit session" + end + + def and_i_turn_off_register_attendance + click_on "Change register attendance" + choose "No" + click_on "Continue" + end end From 2a8ede59742b8d1c66b58ee774fa345232f240b3 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 21 Aug 2025 17:23:57 +0100 Subject: [PATCH 05/27] Add ability for users to enable/disable delgation This adds a new page which users can get to from the "Edit session" page allowing them to enable or disable using PSDs or the national protocol at a per-session level. Jira-Issue: MAV-1357 --- app/controllers/sessions/edit_controller.rb | 17 ++++ app/views/sessions/edit/delegation.html.erb | 38 ++++++++ app/views/sessions/edit/show.html.erb | 16 +++- config/routes.rb | 3 + spec/features/delegation_spec.rb | 99 +++++++++++++++++++++ 5 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 app/views/sessions/edit/delegation.html.erb create mode 100644 spec/features/delegation_spec.rb diff --git a/app/controllers/sessions/edit_controller.rb b/app/controllers/sessions/edit_controller.rb index 35c9af7145..4f92bd9fdf 100644 --- a/app/controllers/sessions/edit_controller.rb +++ b/app/controllers/sessions/edit_controller.rb @@ -10,6 +10,7 @@ class Sessions::EditController < ApplicationController update_send_invitations_at update_weeks_before_consent_reminders update_register_attendance + update_delegation ] before_action :authorize_session_update, only: %i[ @@ -18,6 +19,7 @@ class Sessions::EditController < ApplicationController update_send_invitations_at update_weeks_before_consent_reminders update_register_attendance + update_delegation ] def show @@ -93,6 +95,17 @@ def update_register_attendance end end + def delegation + end + + def update_delegation + if @session.update(delegation_params) + redirect_to session_edit_path(@session) + else + render :delegation, status: :unprocessable_content + end + end + private def set_session @@ -144,4 +157,8 @@ def weeks_before_consent_reminders_params def register_attendance_params params.expect(session: :requires_registration) end + + def delegation_params + params.expect(session: %i[psd_enabled national_protocol_enabled]) + end end diff --git a/app/views/sessions/edit/delegation.html.erb b/app/views/sessions/edit/delegation.html.erb new file mode 100644 index 0000000000..996bf8d1ba --- /dev/null +++ b/app/views/sessions/edit/delegation.html.erb @@ -0,0 +1,38 @@ +<% content_for :before_main do %> + <%= govuk_back_link(href: session_edit_path(@session)) %> +<% end %> + +<%= form_with model: @session, url: delegation_session_edit_path(@session), method: :put do |f| %> + <% content_for(:before_content) { f.govuk_error_summary } %> + + <%= @session.location.name %> + <%= h1 "Delegation" %> + + <%= f.govuk_radio_buttons_fieldset :psd_enabled, + legend: { text: "Can healthcare assistants administer the flu nasal spray vaccine using a patient specific direction (PSD)?" }, + link_errors: true do %> + <%= f.govuk_radio_button :psd_enabled, + "false", + label: { text: "No" }, + hint: { text: "Healthcare assistants can only administer a nasal spray when supplied by a nurse" } %> + <%= f.govuk_radio_button :psd_enabled, + "true", + label: { text: "Yes" }, + hint: { text: "Healthcare assistants can administer the nasal spray vaccine to children who are covered by a PSD" } %> + <% end %> + + <%= f.govuk_radio_buttons_fieldset :national_protocol_enabled, + legend: { text: "Can healthcare assistants administer the injected flu vaccine using the national protocol?" }, + link_errors: true do %> + <%= f.govuk_radio_button :national_protocol_enabled, + "false", + label: { text: "No" }, + hint: { text: "Only nurses can administer the injected flu vaccine" } %> + <%= f.govuk_radio_button :national_protocol_enabled, + "true", + label: { text: "Yes" }, + hint: { text: "Healthcare assistants can administer an injected flu vaccine when supplied by a nurse" } %> + <% end %> + + <%= f.govuk_submit "Continue" %> +<% end %> diff --git a/app/views/sessions/edit/show.html.erb b/app/views/sessions/edit/show.html.erb index 6d60634e38..b0d33bf9c6 100644 --- a/app/views/sessions/edit/show.html.erb +++ b/app/views/sessions/edit/show.html.erb @@ -69,9 +69,23 @@ summary_list.with_row do |row| row.with_key { "Register attendance" } - row.with_value { @session.requires_registration ? "Yes" : "No" } + row.with_value { @session.requires_registration? ? "Yes" : "No" } row.with_action(text: "Change", href: register_attendance_session_edit_path(@session), visually_hidden_text: "register attendance") end + + if Flipper.enabled?(:delegation) && @session.supports_delegation? + summary_list.with_row do |row| + row.with_key { "Use patient specific direction (PSD)" } + row.with_value { @session.psd_enabled? ? "Yes" : "No" } + row.with_action(text: "Change", href: delegation_session_edit_path(@session), visually_hidden_text: "use patient specific direction (PSD)") + end + + summary_list.with_row do |row| + row.with_key { "Use national protocol" } + row.with_value { @session.national_protocol_enabled? ? "Yes" : "No" } + row.with_action(text: "Change", href: delegation_session_edit_path(@session), visually_hidden_text: "use national protocol") + end + end end %> <% end %> diff --git a/config/routes.rb b/config/routes.rb index a173ebc6cd..f6b1d37cde 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -241,6 +241,9 @@ get "register-attendance" put "register-attendance", action: :update_register_attendance + + get "delegation" + put "delegation", action: :update_delegation end resource :invite_to_clinic, diff --git a/spec/features/delegation_spec.rb b/spec/features/delegation_spec.rb new file mode 100644 index 0000000000..ca57c80089 --- /dev/null +++ b/spec/features/delegation_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +describe "Delegation" do + after { given_the_feature_flag_is_disabled } + + scenario "feature flag off and in a flu session" do + given_the_feature_flag_is_disabled + and_a_flu_session_exists + + when_i_visit_the_session_as_a_nurse + and_i_go_to_the_edit_page + then_i_see_nothing_about_delegation + end + + scenario "feature flag on and in a HPV session" do + given_the_feature_flag_is_enabled + and_an_hpv_session_exists + + when_i_visit_the_session_as_a_nurse + and_i_go_to_the_edit_page + then_i_see_nothing_about_delegation + end + + scenario "feature flag on and in a flu session" do + given_the_feature_flag_is_enabled + and_a_flu_session_exists + + when_i_visit_the_session_as_a_nurse + and_i_go_to_the_edit_page + then_i_see_the_delegation_options + + when_i_enable_delegation + then_i_see_the_options_are_enabled + end + + def given_the_feature_flag_is_enabled + Flipper.enable(:delegation) + end + + def given_the_feature_flag_is_disabled + Flipper.disable(:delegation) + end + + def and_a_flu_session_exists + @programme = create(:programme, :flu) + @team = create(:team, programmes: [@programme]) + @nurse = create(:nurse, teams: [@team]) + + @session = create(:session, programmes: [@programme], team: @team) + end + + def and_an_hpv_session_exists + @programme = create(:programme, :hpv) + @team = create(:team, programmes: [@programme]) + @nurse = create(:nurse, teams: [@team]) + + @session = create(:session, programmes: [@programme], team: @team) + end + + def when_i_visit_the_session_as_a_nurse + sign_in @nurse + visit session_path(@session) + end + + def and_i_go_to_the_edit_page + click_on "Edit session" + end + + def then_i_see_nothing_about_delegation + expect(page).not_to have_content("patient specific direction") + expect(page).not_to have_content("national protocol") + end + + def then_i_see_the_delegation_options + expect(page).to have_content("Use patient specific direction (PSD)") + expect(page).to have_content("Use national protocol") + end + + def when_i_enable_delegation + click_on "Change use patient specific direction (PSD)" + + # PSD + within all(".nhsuk-fieldset")[0] do + choose "Yes" + end + + # National protocol + within all(".nhsuk-fieldset")[1] do + choose "Yes" + end + + click_on "Continue" + end + + def then_i_see_the_options_are_enabled + expect(page).to have_content("Use patient specific direction (PSD)Yes") + expect(page).to have_content("Use national protocolYes") + end +end From ee39713476fb1ef1e506d5308bbc50f19be23bd5 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 21 Aug 2025 11:09:50 +0100 Subject: [PATCH 06/27] Add `is_prescriber?` This adds a method to `User` and `CIS2Info` which can be used to determine with a particular user has permission to prescribe a vaccination using either a PSD or the national protocol. Jira-Issue: MAV-1400 --- .../concerns/authentication_concern.rb | 3 +- app/models/cis2_info.rb | 5 +++ app/models/user.rb | 6 +++ spec/factories/users.rb | 8 ++++ spec/models/user_spec.rb | 44 +++++++++++++++++++ 5 files changed, 65 insertions(+), 1 deletion(-) diff --git a/app/controllers/concerns/authentication_concern.rb b/app/controllers/concerns/authentication_concern.rb index ffff28bc18..9e67bb530c 100644 --- a/app/controllers/concerns/authentication_concern.rb +++ b/app/controllers/concerns/authentication_concern.rb @@ -50,7 +50,8 @@ def selected_cis2_workgroup_is_valid? def selected_cis2_role_is_valid? cis2_info.is_admin? || cis2_info.is_nurse? || - cis2_info.is_healthcare_assistant? || cis2_info.is_superuser? + cis2_info.is_healthcare_assistant? || cis2_info.is_superuser? || + cis2_info.is_prescriber? end def storable_location? diff --git a/app/models/cis2_info.rb b/app/models/cis2_info.rb index 4d6e620c0c..c3052ba68e 100644 --- a/app/models/cis2_info.rb +++ b/app/models/cis2_info.rb @@ -8,6 +8,7 @@ class CIS2Info SUPERUSER_WORKGROUP = "mavissuperusers" + INDEPENDENT_PRESCRIBING_ACTIVITY_CODE = "B0420" PERSONAL_MEDICATION_ADMINISTRATION_ACTIVITY_CODE = "B0428" attribute :organisation_name @@ -50,6 +51,10 @@ def is_healthcare_assistant? activity_codes.include?(PERSONAL_MEDICATION_ADMINISTRATION_ACTIVITY_CODE) end + def is_prescriber? + activity_codes.include?(INDEPENDENT_PRESCRIBING_ACTIVITY_CODE) + end + def is_superuser? workgroups.include?(SUPERUSER_WORKGROUP) end diff --git a/app/models/user.rb b/app/models/user.rb index 75dad8c76e..ce86d95667 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -126,6 +126,8 @@ def role_description role = if is_healthcare_assistant? "Healthcare Assistant" + elsif is_prescriber? + "Prescriber" elsif is_nurse? "Nurse" else @@ -151,6 +153,10 @@ def is_healthcare_assistant? end end + def is_prescriber? + cis2_enabled? ? cis2_info.is_prescriber? : fallback_role_prescriber? + end + def is_superuser? cis2_enabled? ? cis2_info.is_superuser? : fallback_role_superuser? end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 07f7b46942..dc57218e5c 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -100,6 +100,13 @@ fallback_role { :healthcare_assistant } end + trait :prescriber do + sequence(:email) { |n| "prescriber-#{n}@example.com" } + role_code { nil } + activity_codes { [CIS2Info::INDEPENDENT_PRESCRIBING_ACTIVITY_CODE] } + fallback_role { :prescriber } + end + trait :signed_in do current_sign_in_at { Time.current } current_sign_in_ip { "127.0.0.1" } @@ -108,5 +115,6 @@ factory :admin, parent: :user, traits: %i[admin] factory :healthcare_assistant, parent: :user, traits: %i[healthcare_assistant] + factory :prescriber, parent: :user, traits: %i[prescriber] factory :superuser, parent: :user, traits: %i[superuser] end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index ecf02f3694..9ecfe871a3 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -196,6 +196,50 @@ end end + describe "#is_prescriber?" do + subject { user.is_prescriber? } + + context "cis2 is enabled", cis2: :enabled do + context "when the user is a nurse" do + let(:user) { build(:nurse) } + + it { should be(false) } + end + + context "when the user is admin staff" do + let(:user) { build(:admin) } + + it { should be(false) } + end + + context "when the user is a prescriber" do + let(:user) { build(:prescriber) } + + it { should be(true) } + end + end + + context "cis2 is disabled", cis2: :disabled do + context "when the user is a nurse" do + let(:user) { build(:nurse) } + + it { should be(false) } + end + + context "when the user is admin staff" do + let(:user) { build(:admin) } + + it { should be(false) } + end + + context "when the user is a prescriber" do + let(:user) { build(:prescriber) } + + it { should be(true) } + end + end + end + describe "#is_superuser?" do subject { user.is_superuser? } From 5fefcb838dec4183e84ee3562505bf95c056132a Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Fri, 22 Aug 2025 20:32:38 +0100 Subject: [PATCH 07/27] Update policies for healthcare assistances This updates the programme and session policy to ensure that if a user is a healthcare assistant they can only see sessions and programmes which support delegation, which at the moment is limited to flu. Jira-Issue: MAV-1353 --- app/policies/programme_policy.rb | 7 +++- app/policies/session_policy.rb | 6 +++- spec/policies/programme_policy_spec.rb | 32 +++++++++++++++++++ spec/policies/session_policy_spec.rb | 44 +++++++++++++++++++++++--- 4 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 spec/policies/programme_policy_spec.rb diff --git a/app/policies/programme_policy.rb b/app/policies/programme_policy.rb index 90ca12d5a5..e6b7cb86a9 100644 --- a/app/policies/programme_policy.rb +++ b/app/policies/programme_policy.rb @@ -9,7 +9,12 @@ def consent_form? = show? class Scope < ApplicationPolicy::Scope def resolve - scope.where(id: user.selected_team.programmes.ids) + scope + .joins(:team_programmes) + .where(team_programmes: { team: user.selected_team }) + .then do |scope| + user.is_healthcare_assistant? ? scope.supports_delegation : scope + end end end end diff --git a/app/policies/session_policy.rb b/app/policies/session_policy.rb index 3c2d9e2866..acfc8b073f 100644 --- a/app/policies/session_policy.rb +++ b/app/policies/session_policy.rb @@ -11,7 +11,11 @@ def make_in_progress? = edit? class Scope < ApplicationPolicy::Scope def resolve - scope.where(team: user.selected_team) + scope + .where(team: user.selected_team) + .then do |scope| + user.is_healthcare_assistant? ? scope.supports_delegation : scope + end end end end diff --git a/spec/policies/programme_policy_spec.rb b/spec/policies/programme_policy_spec.rb new file mode 100644 index 0000000000..6f36f2e663 --- /dev/null +++ b/spec/policies/programme_policy_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +describe ProgrammePolicy do + describe ProgrammePolicy::Scope do + describe "#resolve" do + subject { described_class.new(user, Programme).resolve } + + let(:flu_programme) { create(:programme, :flu) } + let(:hpv_programme) { create(:programme, :hpv) } + + let(:team) { create(:team, programmes: [flu_programme, hpv_programme]) } + + context "with an admin user" do + let(:user) { create(:admin, team:) } + + it { should contain_exactly(flu_programme, hpv_programme) } + end + + context "with a nurse user" do + let(:user) { create(:nurse, team:) } + + it { should contain_exactly(flu_programme, hpv_programme) } + end + + context "with a healthcare assistant user" do + let(:user) { create(:healthcare_assistant, team:) } + + it { should contain_exactly(flu_programme) } + end + end + end +end diff --git a/spec/policies/session_policy_spec.rb b/spec/policies/session_policy_spec.rb index 83c9054f9b..d93fffd38c 100644 --- a/spec/policies/session_policy_spec.rb +++ b/spec/policies/session_policy_spec.rb @@ -69,15 +69,51 @@ describe "#resolve" do subject { described_class.new(user, Session).resolve } - let(:programmes) { [create(:programme)] } - let(:team) { create(:team, programmes:) } let(:user) { create(:user, team:) } + let!(:flu_programme) { create(:programme, :flu) } + let!(:hpv_programme) { create(:programme, :hpv) } + let(:users_teams_session) { create(:session, team:, programmes:) } let(:another_teams_session) { create(:session, programmes:) } - it { should include(users_teams_session) } - it { should_not include(another_teams_session) } + let(:programmes) { [hpv_programme] } + let(:team) { create(:team, programmes:) } + + context "with a session part of the user's teams" do + let(:session) { create(:session, team:, programmes:) } + + context "and an admin user" do + let(:user) { create(:admin, team:) } + + it { should include(session) } + end + + context "and a nurse user" do + let(:user) { create(:nurse, team:) } + + it { should include(session) } + end + + context "and a healthcare assistant user" do + let(:user) { create(:healthcare_assistant, team:) } + + it { should_not include(session) } + + context "and a flu session" do + let(:programmes) { [flu_programme] } + + it { should include(session) } + end + end + end + + context "with a session not part of the user's teams" do + let(:session) { create(:session, programmes:) } + let(:user) { create(:user, team:) } + + it { should_not include(session) } + end end end end From 26b9bbbaa5b9c5e5e63330af2107a9066151d990 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Mon, 25 Aug 2025 17:45:25 +0100 Subject: [PATCH 08/27] Add `Session#vaccine_methods_for` This adds a method which can be used to determine which vaccine methods are approriate for a user in a particular session. This will be used to limit which patients are shown to which users. Jira-Issue: MAV-1353 --- app/models/session.rb | 12 +++++++++++- spec/models/session_spec.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/app/models/session.rb b/app/models/session.rb index 765452bed1..9f907c491f 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -205,7 +205,7 @@ def year_groups end def vaccine_methods - programmes.flat_map(&:vaccine_methods).uniq.sort + @vaccine_methods ||= programmes.flat_map(&:vaccine_methods).uniq.sort end def programmes_for(year_group: nil, patient: nil, academic_year: nil) @@ -218,6 +218,16 @@ def programmes_for(year_group: nil, patient: nil, academic_year: nil) end end + def vaccine_methods_for(user:) + if user.is_nurse? + vaccine_methods + elsif user.is_healthcare_assistant? && pgd_supply_enabled? + %w[nasal] + else + [] + end + end + def dates session_dates.map(&:value).compact end diff --git a/spec/models/session_spec.rb b/spec/models/session_spec.rb index e4cf9925b0..18ea1316ce 100644 --- a/spec/models/session_spec.rb +++ b/spec/models/session_spec.rb @@ -296,6 +296,32 @@ it { should contain_exactly("injection", "nasal") } end + describe "#vaccine_methods_for" do + subject { session.vaccine_methods_for(user:) } + + let(:programmes) { [create(:programme, :flu)] } + + let(:session) { create(:session, programmes:) } + + context "with a nurse" do + let(:user) { create(:nurse) } + + it { should match_array(%w[nasal injection]) } + end + + context "with a healthcare assistant" do + let(:user) { create(:healthcare_assistant) } + + it { should eq(%w[nasal]) } + end + + context "with an admin staff" do + let(:user) { create(:admin) } + + it { should be_empty } + end + end + describe "#today_or_future_dates" do subject(:today_or_future_dates) do travel_to(today) { session.today_or_future_dates } From 75a35ae6d7438607ca8482095964f105daa7c91e Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Mon, 25 Aug 2025 19:56:41 +0100 Subject: [PATCH 09/27] Don't render vaccination section heading If the user is not allowed to perform a vaccination we should hide the heading as well as the form since it doesn't make sense to show the heading. Jira-Issue: MAV-1353 --- app/components/app_patient_session_record_component.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/components/app_patient_session_record_component.rb b/app/components/app_patient_session_record_component.rb index 6322d761d1..e3afd26804 100644 --- a/app/components/app_patient_session_record_component.rb +++ b/app/components/app_patient_session_record_component.rb @@ -2,9 +2,8 @@ class AppPatientSessionRecordComponent < ViewComponent::Base erb_template <<-ERB -

<%= heading %>

- <% if helpers.policy(VaccinationRecord).new? %> +

<%= heading %>

<%= render AppVaccinateFormComponent.new(vaccinate_form) %> <% end %> ERB From 63df0c890030dd3b04db35b65f25efcdc511ae0f Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Mon, 25 Aug 2025 19:56:57 +0100 Subject: [PATCH 10/27] Refactor recording already vaccinated This moves the method related to determining whether the user is able to take this action in to the policy method, closer to where it would be used. Jira-Issue: MAV-1353 --- .../patient_sessions/programmes_controller.rb | 10 +++++----- app/models/patient_session.rb | 5 ----- app/policies/vaccination_record_policy.rb | 8 ++++++++ app/views/patient_sessions/_header.html.erb | 2 +- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/app/controllers/patient_sessions/programmes_controller.rb b/app/controllers/patient_sessions/programmes_controller.rb index 75f617552c..669b2e9278 100644 --- a/app/controllers/patient_sessions/programmes_controller.rb +++ b/app/controllers/patient_sessions/programmes_controller.rb @@ -8,11 +8,11 @@ def show end def record_already_vaccinated - unless @patient_session.can_record_as_already_vaccinated?( - programme: @programme - ) - redirect_to session_patient_path and return - end + authorize VaccinationRecord.new( + patient: @patient, + session: @session, + programme: @programme + ) draft_vaccination_record = DraftVaccinationRecord.new(request_session: session, current_user:) diff --git a/app/models/patient_session.rb b/app/models/patient_session.rb index 15f6339b2f..336f7aa464 100644 --- a/app/models/patient_session.rb +++ b/app/models/patient_session.rb @@ -277,11 +277,6 @@ def destroy_if_safe! destroy! if safe_to_destroy? end - def can_record_as_already_vaccinated?(programme:) - !session.today? && - patient.vaccination_status(programme:, academic_year:).none_yet? - end - def programmes = session.programmes_for(patient:, academic_year:) def todays_attendance diff --git a/app/policies/vaccination_record_policy.rb b/app/policies/vaccination_record_policy.rb index 8148875788..d032c924ed 100644 --- a/app/policies/vaccination_record_policy.rb +++ b/app/policies/vaccination_record_policy.rb @@ -9,6 +9,11 @@ def new? create? end + def record_already_vaccinated? + user.is_nurse? && !session.today? && + patient.vaccination_status(programme:, academic_year:).none_yet? + end + def edit? user.is_nurse? && record.session_id.present? && record.performed_ods_code == user.selected_organisation.ods_code @@ -22,6 +27,9 @@ def destroy? user.is_superuser? end + delegate :patient, :session, :programme, to: :record + delegate :academic_year, to: :session + class Scope < ApplicationPolicy::Scope def resolve organisation = user.selected_organisation diff --git a/app/views/patient_sessions/_header.html.erb b/app/views/patient_sessions/_header.html.erb index 0cc87b922a..96a191679e 100644 --- a/app/views/patient_sessions/_header.html.erb +++ b/app/views/patient_sessions/_header.html.erb @@ -39,7 +39,7 @@ <% end %> <% end %> - <% if policy(VaccinationRecord).create? && @programme && @patient_session.can_record_as_already_vaccinated?(programme: @programme) %> + <% if @programme && policy(VaccinationRecord.new(patient: @patient, session: @session, programme: @programme)).record_already_vaccinated? %>
  • <%= link_to "Record as already vaccinated", session_patient_programme_record_already_vaccinated_path(@session, @patient, @programme) %> From 908754374ab7afd055f0bfd31fb5e867a2d2973c Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Mon, 25 Aug 2025 17:11:29 +0100 Subject: [PATCH 11/27] Pass VaccinationRecord instance to policy This allows us to check various properties on the vaccination record to support delegation where only certain users will be able to perform certain actions. Jira-Issue: MAV-1353 --- app/components/app_patient_session_record_component.rb | 6 +++++- app/controllers/draft_vaccination_records_controller.rb | 7 ++++++- .../patient_sessions/vaccinations_controller.rb | 6 +++++- app/models/draft_vaccination_record.rb | 6 +++++- app/views/patient_sessions/_header.html.erb | 8 ++++++-- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/app/components/app_patient_session_record_component.rb b/app/components/app_patient_session_record_component.rb index e3afd26804..9967d9b9c3 100644 --- a/app/components/app_patient_session_record_component.rb +++ b/app/components/app_patient_session_record_component.rb @@ -2,7 +2,7 @@ class AppPatientSessionRecordComponent < ViewComponent::Base erb_template <<-ERB - <% if helpers.policy(VaccinationRecord).new? %> + <% if helpers.policy(vaccination_record).new? %>

    <%= heading %>

    <%= render AppVaccinateFormComponent.new(vaccinate_form) %> <% end %> @@ -32,6 +32,10 @@ def render? delegate :patient, :session, to: :patient_session delegate :academic_year, to: :session + def vaccination_record + VaccinationRecord.new(patient:, session:, programme:) + end + def default_vaccinate_form pre_screening_confirmed = patient.pre_screenings.today.exists?(programme:) session_date = session.session_dates.today.first diff --git a/app/controllers/draft_vaccination_records_controller.rb b/app/controllers/draft_vaccination_records_controller.rb index f6f14ff349..ffdee3518b 100644 --- a/app/controllers/draft_vaccination_records_controller.rb +++ b/app/controllers/draft_vaccination_records_controller.rb @@ -193,7 +193,12 @@ def set_programme def set_vaccination_record @vaccination_record = - @draft_vaccination_record.vaccination_record || VaccinationRecord.new + @draft_vaccination_record.vaccination_record || + VaccinationRecord.new( + patient: @patient, + session: @session, + programme: @programme + ) end def set_steps diff --git a/app/controllers/patient_sessions/vaccinations_controller.rb b/app/controllers/patient_sessions/vaccinations_controller.rb index 72a9b2268c..03b54e9750 100644 --- a/app/controllers/patient_sessions/vaccinations_controller.rb +++ b/app/controllers/patient_sessions/vaccinations_controller.rb @@ -10,7 +10,11 @@ class PatientSessions::VaccinationsController < PatientSessions::BaseController after_action :verify_authorized def create - authorize VaccinationRecord + authorize VaccinationRecord.new( + patient: @patient, + session: @session, + programme: @programme + ) draft_vaccination_record = DraftVaccinationRecord.new(request_session: session, current_user:) diff --git a/app/models/draft_vaccination_record.rb b/app/models/draft_vaccination_record.rb index 5be3d2e840..fefba2efc4 100644 --- a/app/models/draft_vaccination_record.rb +++ b/app/models/draft_vaccination_record.rb @@ -187,7 +187,11 @@ def programme=(value) def session return nil if session_id.nil? - SessionPolicy::Scope.new(@current_user, Session).resolve.find(session_id) + SessionPolicy::Scope + .new(@current_user, Session) + .resolve + .includes(:programmes) + .find(session_id) end def session=(value) diff --git a/app/views/patient_sessions/_header.html.erb b/app/views/patient_sessions/_header.html.erb index 96a191679e..471c6ba2c5 100644 --- a/app/views/patient_sessions/_header.html.erb +++ b/app/views/patient_sessions/_header.html.erb @@ -9,9 +9,13 @@ ].compact) %> <% end %> -<% if policy(VaccinationRecord).new? && (outstanding_programmes = @patient_session.outstanding_programmes).any? %> +<% if (outstanding_programmes = @patient_session.outstanding_programmes).any? %> + <% programmes_can_record_vaccination = outstanding_programmes.filter do |programme| + policy(VaccinationRecord.new(patient: @patient, session: @session, programme:)).new? + end %> + <%= govuk_notification_banner(title_text: "Important") do |notification_banner| %> - <% notification_banner.with_heading(text: "You still need to record an outcome for #{outstanding_programmes.map(&:name).to_sentence}.") %> + <% notification_banner.with_heading(text: "You still need to record an outcome for #{programmes_can_record_vaccination.map(&:name).to_sentence}.") %> <% end %> <% end %> From 6e4bc0f45b43356b8d38fb903bdcfb2c658823ce Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Mon, 25 Aug 2025 19:01:55 +0100 Subject: [PATCH 12/27] Restrict patients by user This makes it so that healthcare assistants don't have permission to record vaccinations against injection patients by hiding them from the "Record" tab. Jira-Issue: MAV-1353 --- app/controllers/sessions/record_controller.rb | 16 ++++ app/policies/vaccination_record_policy.rb | 20 ++-- app/views/sessions/record/show.html.erb | 2 +- config/initializers/inflections.rb | 1 + .../flu_vaccination_hca_pgd_supply_spec.rb | 93 +++++++++++++++++++ 5 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 spec/features/flu_vaccination_hca_pgd_supply_spec.rb diff --git a/app/controllers/sessions/record_controller.rb b/app/controllers/sessions/record_controller.rb index ffbd77d3c7..de56fff23b 100644 --- a/app/controllers/sessions/record_controller.rb +++ b/app/controllers/sessions/record_controller.rb @@ -25,6 +25,22 @@ def show scope = scope.has_registration_status(%w[attending completed]) end + @vaccine_methods = @session.vaccine_methods_for(user: current_user) + + if @vaccine_methods != @session.vaccine_methods + scope = + if @vaccine_methods.empty? + scope.none + else + @vaccine_methods.reduce(scope) do |accumulator, vaccine_method| + accumulator.has_vaccine_method( + vaccine_method, + programme: @session.programmes + ) + end + end + end + patient_sessions = @form.apply(scope).consent_given_and_ready_to_vaccinate( programmes: @form.programmes, diff --git a/app/policies/vaccination_record_policy.rb b/app/policies/vaccination_record_policy.rb index d032c924ed..ebe1c66d53 100644 --- a/app/policies/vaccination_record_policy.rb +++ b/app/policies/vaccination_record_policy.rb @@ -2,12 +2,14 @@ class VaccinationRecordPolicy < ApplicationPolicy def create? - user.is_nurse? + user.is_nurse? || + ( + patient.approved_vaccine_methods(programme:, academic_year:) & + session.vaccine_methods_for(user:) + ).present? end - def new? - create? - end + def new? = create? def record_already_vaccinated? user.is_nurse? && !session.today? && @@ -15,17 +17,13 @@ def record_already_vaccinated? end def edit? - user.is_nurse? && record.session_id.present? && + user.is_nurse? && record.recorded_in_service? && record.performed_ods_code == user.selected_organisation.ods_code end - def update? - edit? - end + def update? = edit? - def destroy? - user.is_superuser? - end + def destroy? = user.is_superuser? delegate :patient, :session, :programme, to: :record delegate :academic_year, to: :session diff --git a/app/views/sessions/record/show.html.erb b/app/views/sessions/record/show.html.erb index e72878d8be..22696722e1 100644 --- a/app/views/sessions/record/show.html.erb +++ b/app/views/sessions/record/show.html.erb @@ -45,7 +45,7 @@ url: session_record_path(@session), programmes: @session.programmes, year_groups: @session.year_groups, - vaccine_methods: @session.vaccine_methods.then { it.length > 1 ? it : [] }, + vaccine_methods: @vaccine_methods.length > 1 ? @vaccine_methods : [], ) %>
  • diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index fe502d3e48..f0899e9a52 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -23,6 +23,7 @@ inflect.acronym "FHIR" inflect.acronym "GIAS" inflect.acronym "GP" + inflect.acronym "HCA" inflect.acronym "JWKS" inflect.acronym "NHS" inflect.acronym "OAuth2" diff --git a/spec/features/flu_vaccination_hca_pgd_supply_spec.rb b/spec/features/flu_vaccination_hca_pgd_supply_spec.rb new file mode 100644 index 0000000000..38755a9891 --- /dev/null +++ b/spec/features/flu_vaccination_hca_pgd_supply_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +describe "Flu vaccination" do + around { |example| travel_to(Time.zone.local(2024, 10, 1)) { example.run } } + + scenario "Administered by HCA" do + given_a_session_exists + and_patients_exist + + when_i_visit_the_session_record_tab + then_i_only_see_nasal_spray_patients + + when_i_click_on_the_nasal_only_patient + then_i_am_able_to_vaccinate_them + + when_i_click_on_the_nasal_and_injection_patient + then_i_am_able_to_vaccinate_them + + when_i_click_on_the_injection_patient + then_i_am_not_able_to_vaccinate_them + end + + def given_a_session_exists + programmes = [create(:programme, :flu)] + + @team = create(:team, programmes:) + @user = create(:healthcare_assistant, team: @team) + + @session = + create( + :session, + :today, + :requires_no_registration, + team: @team, + programmes: + ) + end + + def and_patients_exist + @patient_nasal_only = + create( + :patient, + :consent_given_nasal_only_triage_not_needed, + session: @session + ) + @patient_nasal_and_injection = + create( + :patient, + :consent_given_nasal_or_injection_triage_not_needed, + session: @session + ) + @patient_injection_only = + create( + :patient, + :consent_given_injection_only_triage_not_needed, + session: @session + ) + end + + def when_i_visit_the_session_record_tab + sign_in @user, role: :healthcare_assistant + visit session_record_path(@session) + end + + def then_i_only_see_nasal_spray_patients + expect(page).to have_content(@patient_nasal_only.full_name) + expect(page).to have_content(@patient_nasal_and_injection.full_name) + expect(page).not_to have_content(@patient_injection_only.full_name) + end + + def when_i_click_on_the_nasal_only_patient + click_on @patient_nasal_only.full_name + end + + def when_i_click_on_the_nasal_and_injection_patient + click_on @patient_nasal_and_injection.full_name + end + + def when_i_click_on_the_injection_patient + # This patient won't be in the "Record" tab. + within(".app-secondary-navigation") { click_on "Children" } + click_on @patient_injection_only.full_name + end + + def then_i_am_able_to_vaccinate_them + click_on "Record vaccinations" + end + + def then_i_am_not_able_to_vaccinate_them + expect(page).not_to have_content("Pre-screening checks") + expect(page).not_to have_content("ready for their") + end +end From cbc21e74d8e28940b8cabf9baaae179426f063ed Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Mon, 25 Aug 2025 20:38:24 +0100 Subject: [PATCH 13/27] Add supplier user to vaccination records This allows us to track who supplied the vaccine to the person who performed the vaccination. Jira-Issue: MAV-1353 --- app/models/vaccination_record.rb | 10 +++++++++- ...232_add_supplied_by_user_to_vaccination_records.rb | 11 +++++++++++ db/schema.rb | 3 +++ spec/factories/vaccination_records.rb | 3 +++ spec/models/vaccination_record_spec.rb | 4 ++++ 5 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20250825183232_add_supplied_by_user_to_vaccination_records.rb diff --git a/app/models/vaccination_record.rb b/app/models/vaccination_record.rb index beae07c967..bb38288ba4 100644 --- a/app/models/vaccination_record.rb +++ b/app/models/vaccination_record.rb @@ -34,6 +34,7 @@ # performed_by_user_id :bigint # programme_id :bigint not null # session_id :bigint +# supplied_by_user_id :bigint # vaccine_id :bigint # # Indexes @@ -46,6 +47,7 @@ # index_vaccination_records_on_performed_by_user_id (performed_by_user_id) # index_vaccination_records_on_programme_id (programme_id) # index_vaccination_records_on_session_id (session_id) +# index_vaccination_records_on_supplied_by_user_id (supplied_by_user_id) # index_vaccination_records_on_uuid (uuid) UNIQUE # index_vaccination_records_on_vaccine_id (vaccine_id) # @@ -56,6 +58,7 @@ # fk_rails_... (performed_by_user_id => users.id) # fk_rails_... (programme_id => programmes.id) # fk_rails_... (session_id => sessions.id) +# fk_rails_... (supplied_by_user_id => users.id) # fk_rails_... (vaccine_id => vaccines.id) # class VaccinationRecord < ApplicationRecord @@ -87,9 +90,14 @@ class VaccinationRecord < ApplicationRecord belongs_to :batch, optional: true belongs_to :vaccine, optional: true - belongs_to :performed_by_user, class_name: "User", optional: true belongs_to :programme + belongs_to :performed_by_user, class_name: "User", optional: true + belongs_to :supplied_by, + class_name: "User", + foreign_key: :supplied_by_user_id, + optional: true + has_and_belongs_to_many :immunisation_imports belongs_to :location, optional: true diff --git a/db/migrate/20250825183232_add_supplied_by_user_to_vaccination_records.rb b/db/migrate/20250825183232_add_supplied_by_user_to_vaccination_records.rb new file mode 100644 index 0000000000..cbcc185e83 --- /dev/null +++ b/db/migrate/20250825183232_add_supplied_by_user_to_vaccination_records.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddSuppliedByUserToVaccinationRecords < ActiveRecord::Migration[8.0] + def change + add_reference :vaccination_records, + :supplied_by_user, + foreign_key: { + to_table: :users + } + end +end diff --git a/db/schema.rb b/db/schema.rb index 6bdbf8bda5..26d6f8ea3d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -951,6 +951,7 @@ t.datetime "nhs_immunisations_api_sync_pending_at" t.boolean "notify_parents" t.bigint "location_id" + t.bigint "supplied_by_user_id" t.index ["batch_id"], name: "index_vaccination_records_on_batch_id" t.index ["discarded_at"], name: "index_vaccination_records_on_discarded_at" t.index ["location_id"], name: "index_vaccination_records_on_location_id" @@ -959,6 +960,7 @@ t.index ["performed_by_user_id"], name: "index_vaccination_records_on_performed_by_user_id" t.index ["programme_id"], name: "index_vaccination_records_on_programme_id" t.index ["session_id"], name: "index_vaccination_records_on_session_id" + t.index ["supplied_by_user_id"], name: "index_vaccination_records_on_supplied_by_user_id" t.index ["uuid"], name: "index_vaccination_records_on_uuid", unique: true t.index ["vaccine_id"], name: "index_vaccination_records_on_vaccine_id" end @@ -1106,6 +1108,7 @@ add_foreign_key "vaccination_records", "programmes" add_foreign_key "vaccination_records", "sessions" add_foreign_key "vaccination_records", "users", column: "performed_by_user_id" + add_foreign_key "vaccination_records", "users", column: "supplied_by_user_id" add_foreign_key "vaccination_records", "vaccines" add_foreign_key "vaccines", "programmes" end diff --git a/spec/factories/vaccination_records.rb b/spec/factories/vaccination_records.rb index 69e37ad9dd..b5ef753a34 100644 --- a/spec/factories/vaccination_records.rb +++ b/spec/factories/vaccination_records.rb @@ -34,6 +34,7 @@ # performed_by_user_id :bigint # programme_id :bigint not null # session_id :bigint +# supplied_by_user_id :bigint # vaccine_id :bigint # # Indexes @@ -46,6 +47,7 @@ # index_vaccination_records_on_performed_by_user_id (performed_by_user_id) # index_vaccination_records_on_programme_id (programme_id) # index_vaccination_records_on_session_id (session_id) +# index_vaccination_records_on_supplied_by_user_id (supplied_by_user_id) # index_vaccination_records_on_uuid (uuid) UNIQUE # index_vaccination_records_on_vaccine_id (vaccine_id) # @@ -56,6 +58,7 @@ # fk_rails_... (performed_by_user_id => users.id) # fk_rails_... (programme_id => programmes.id) # fk_rails_... (session_id => sessions.id) +# fk_rails_... (supplied_by_user_id => users.id) # fk_rails_... (vaccine_id => vaccines.id) # FactoryBot.define do diff --git a/spec/models/vaccination_record_spec.rb b/spec/models/vaccination_record_spec.rb index 2774f7a723..16baeb88ab 100644 --- a/spec/models/vaccination_record_spec.rb +++ b/spec/models/vaccination_record_spec.rb @@ -34,6 +34,7 @@ # performed_by_user_id :bigint # programme_id :bigint not null # session_id :bigint +# supplied_by_user_id :bigint # vaccine_id :bigint # # Indexes @@ -46,6 +47,7 @@ # index_vaccination_records_on_performed_by_user_id (performed_by_user_id) # index_vaccination_records_on_programme_id (programme_id) # index_vaccination_records_on_session_id (session_id) +# index_vaccination_records_on_supplied_by_user_id (supplied_by_user_id) # index_vaccination_records_on_uuid (uuid) UNIQUE # index_vaccination_records_on_vaccine_id (vaccine_id) # @@ -56,6 +58,7 @@ # fk_rails_... (performed_by_user_id => users.id) # fk_rails_... (programme_id => programmes.id) # fk_rails_... (session_id => sessions.id) +# fk_rails_... (supplied_by_user_id => users.id) # fk_rails_... (vaccine_id => vaccines.id) # describe VaccinationRecord do @@ -63,6 +66,7 @@ describe "associations" do it { should have_one(:identity_check).autosave(true).dependent(:destroy) } + it { should belong_to(:supplied_by).optional } end describe "validations" do From 8ea39821f372abd83daa6cd92e1ac9664ac2acaf Mon Sep 17 00:00:00 2001 From: John Henderson Date: Wed, 27 Aug 2025 10:16:14 +0100 Subject: [PATCH 14/27] Remove column PatientSpecificDirection#full_dose We don't need this column because it's already record on the vaccination record. It also simplifies the process of adding a PSD since PSDs will be used only for nasal flu which don't have the concept of dose. --- app/models/patient_specific_direction.rb | 3 --- ...12_remove_full_dose_from_patient_specific_directions.rb | 7 +++++++ db/schema.rb | 3 +-- spec/factories/patient_specific_directions.rb | 6 ------ spec/models/patient_specific_direction_spec.rb | 3 --- 5 files changed, 8 insertions(+), 14 deletions(-) create mode 100644 db/migrate/20250827091512_remove_full_dose_from_patient_specific_directions.rb diff --git a/app/models/patient_specific_direction.rb b/app/models/patient_specific_direction.rb index 571be4e7ef..e121f0e805 100644 --- a/app/models/patient_specific_direction.rb +++ b/app/models/patient_specific_direction.rb @@ -7,7 +7,6 @@ # id :bigint not null, primary key # academic_year :integer not null # delivery_site :integer not null -# full_dose :boolean not null # vaccine_method :integer not null # created_at :datetime not null # updated_at :datetime not null @@ -39,8 +38,6 @@ class PatientSpecificDirection < ApplicationRecord belongs_to :programme belongs_to :vaccine - validates :full_dose, inclusion: { in: [true, false] } - enum :delivery_site, { left_arm_upper_position: 2, diff --git a/db/migrate/20250827091512_remove_full_dose_from_patient_specific_directions.rb b/db/migrate/20250827091512_remove_full_dose_from_patient_specific_directions.rb new file mode 100644 index 0000000000..f3b88e2e54 --- /dev/null +++ b/db/migrate/20250827091512_remove_full_dose_from_patient_specific_directions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class RemoveFullDoseFromPatientSpecificDirections < ActiveRecord::Migration[8.0] + def change + remove_column :patient_specific_directions, :full_dose, :boolean + end +end diff --git a/db/schema.rb b/db/schema.rb index 26d6f8ea3d..218947573c 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_08_26_135132) do +ActiveRecord::Schema[8.0].define(version: 2025_08_27_091512) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_trgm" @@ -641,7 +641,6 @@ t.bigint "vaccine_id", null: false t.integer "vaccine_method", null: false t.integer "delivery_site", null: false - t.boolean "full_dose", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "academic_year", null: false diff --git a/spec/factories/patient_specific_directions.rb b/spec/factories/patient_specific_directions.rb index e0b73a6c82..b1db4560e9 100644 --- a/spec/factories/patient_specific_directions.rb +++ b/spec/factories/patient_specific_directions.rb @@ -7,7 +7,6 @@ # id :bigint not null, primary key # academic_year :integer not null # delivery_site :integer not null -# full_dose :boolean not null # vaccine_method :integer not null # created_at :datetime not null # updated_at :datetime not null @@ -40,11 +39,6 @@ delivery_site { "left_arm_upper_position" } vaccine_method { "injection" } - full_dose { true } academic_year { Time.current.to_date.academic_year } - - trait :half_dose do - full_dose { false } - end end end diff --git a/spec/models/patient_specific_direction_spec.rb b/spec/models/patient_specific_direction_spec.rb index 861f94957c..1ee1fc1dff 100644 --- a/spec/models/patient_specific_direction_spec.rb +++ b/spec/models/patient_specific_direction_spec.rb @@ -7,7 +7,6 @@ # id :bigint not null, primary key # academic_year :integer not null # delivery_site :integer not null -# full_dose :boolean not null # vaccine_method :integer not null # created_at :datetime not null # updated_at :datetime not null @@ -70,7 +69,5 @@ :vaccine_method ).in_array(%w[injection nasal]) end - - it { should allow_values(true, false).for(:full_dose) } end end From 031c32b37fe3b4387a7c351e021b31b522545022 Mon Sep 17 00:00:00 2001 From: John Henderson Date: Wed, 20 Aug 2025 12:02:07 +0100 Subject: [PATCH 15/27] Add new policy PatientSpecificDirectionPolicy This will be used by the UI to determine if the user is allowed to prescribe PSDs. --- .../patient_specific_direction_policy.rb | 7 ++++++ .../patient_specific_direction_policy_spec.rb | 25 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 app/policies/patient_specific_direction_policy.rb create mode 100644 spec/policies/patient_specific_direction_policy_spec.rb diff --git a/app/policies/patient_specific_direction_policy.rb b/app/policies/patient_specific_direction_policy.rb new file mode 100644 index 0000000000..a6071735c8 --- /dev/null +++ b/app/policies/patient_specific_direction_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class PatientSpecificDirectionPolicy < ApplicationPolicy + def create? + user.is_nurse? + end +end diff --git a/spec/policies/patient_specific_direction_policy_spec.rb b/spec/policies/patient_specific_direction_policy_spec.rb new file mode 100644 index 0000000000..ef3680203e --- /dev/null +++ b/spec/policies/patient_specific_direction_policy_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +RSpec.describe PatientSpecificDirectionPolicy do + subject(:policy) { described_class.new(user, PatientSpecificDirection) } + + context "cis2 is disabled", cis2: :disabled do + describe "#create?" do + context "when user is a nurse" do + let(:user) { build(:user, :nurse) } + + it "permits creation" do + expect(policy.create?).to be(true) + end + end + + context "when user is not a nurse" do + let(:user) { build(:user, :healthcare_assistant) } + + it "denies creation" do + expect(policy.create?).to be(false) + end + end + end + end +end From 0274a91a208ea5694120c11268f1f0db75a0f561 Mon Sep 17 00:00:00 2001 From: John Henderson Date: Wed, 20 Aug 2025 16:37:48 +0100 Subject: [PATCH 16/27] Change patient_specific_direction factory to use nasal PSDs are only going to be used for nasal vaccines for the foreseeable future. Therefore, it makes sense to have the factory generate PSDs for nasal. --- spec/factories/patient_specific_directions.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/spec/factories/patient_specific_directions.rb b/spec/factories/patient_specific_directions.rb index b1db4560e9..a95d663555 100644 --- a/spec/factories/patient_specific_directions.rb +++ b/spec/factories/patient_specific_directions.rb @@ -37,8 +37,12 @@ programme vaccine { programme.vaccines.sample || association(:vaccine) } - delivery_site { "left_arm_upper_position" } - vaccine_method { "injection" } + delivery_site { "nose" } + vaccine_method { "nasal" } academic_year { Time.current.to_date.academic_year } + + trait :half_dose do + full_dose { false } + end end end From 51e9dfb76757eeea95bbc5e2c42b8af7d196f3c8 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 26 Aug 2025 15:32:12 +0100 Subject: [PATCH 17/27] Allow HCAs to administer nasal flu This adds support for healthcase assistants to administer the nasal flu vaccine, requiring them to specify which nurse supplied to them the vaccine. Jira-Issue: MAV-1353 --- .../app_patient_session_record_component.rb | 6 ++-- .../app_vaccinate_form_component.html.erb | 16 +++++++++- .../app_vaccinate_form_component.rb | 8 ++--- ...pp_vaccination_record_summary_component.rb | 21 +++++++++++++ .../draft_vaccination_records_controller.rb | 8 ++++- .../vaccinations_controller.rb | 6 ++-- app/forms/vaccinate_form.rb | 21 +++++++++++-- app/models/draft_vaccination_record.rb | 23 ++++++++++++-- app/models/user.rb | 2 ++ app/policies/vaccination_record_policy.rb | 3 +- .../confirm.html.erb | 1 + .../supplier.html.erb | 22 ++++++++++++++ .../patient_sessions/programmes/show.html.erb | 2 +- config/locales/en.yml | 3 ++ ...p_patient_session_record_component_spec.rb | 2 ++ .../app_vaccinate_form_component_spec.rb | 9 ++++-- spec/factories/users.rb | 8 +++-- .../flu_vaccination_hca_pgd_supply_spec.rb | 30 ++++++++++++++++++- spec/forms/vaccinate_form_spec.rb | 6 ++-- 19 files changed, 173 insertions(+), 24 deletions(-) create mode 100644 app/views/draft_vaccination_records/supplier.html.erb diff --git a/app/components/app_patient_session_record_component.rb b/app/components/app_patient_session_record_component.rb index 9967d9b9c3..2a39ecc89f 100644 --- a/app/components/app_patient_session_record_component.rb +++ b/app/components/app_patient_session_record_component.rb @@ -8,11 +8,12 @@ class AppPatientSessionRecordComponent < ViewComponent::Base <% end %> ERB - def initialize(patient_session, programme:, vaccinate_form: nil) + def initialize(patient_session, programme:, current_user:, vaccinate_form:) super @patient_session = patient_session @programme = programme + @current_user = current_user @vaccinate_form = vaccinate_form || default_vaccinate_form end @@ -27,7 +28,7 @@ def render? private - attr_reader :patient_session, :programme, :vaccinate_form + attr_reader :patient_session, :current_user, :programme, :vaccinate_form delegate :patient, :session, to: :patient_session delegate :academic_year, to: :session @@ -41,6 +42,7 @@ def default_vaccinate_form session_date = session.session_dates.today.first VaccinateForm.new( + current_user:, patient:, session_date:, programme:, diff --git a/app/components/app_vaccinate_form_component.html.erb b/app/components/app_vaccinate_form_component.html.erb index ebbdd14b73..afd935956a 100644 --- a/app/components/app_vaccinate_form_component.html.erb +++ b/app/components/app_vaccinate_form_component.html.erb @@ -1,5 +1,5 @@ <%= form_with( - model: vaccinate_form, + model: form, url:, method: :post, class: "nhsuk-card", @@ -54,6 +54,20 @@ <% end %> <%= f.govuk_text_area :pre_screening_notes, label: { text: "Pre-screening notes (optional)" }, rows: 3 %> + + <% if form.requires_supplied_by_user_id? %> + <%= f.govuk_select :supplied_by_user_id, + label: { text: "Which nurse identified and pre-screened the child and supplied the vaccine?" }, + data: { module: "autocomplete" } do %> + <%= tag.option "", value: "" %> + <% form.supplied_by_users.each do |user| %> + <%= tag.option user.full_name, + value: user.id, + selected: user.id == form.supplied_by_user_id, + data: { hint: user.email } %> + <% end %> + <% end %> + <% end %>
    diff --git a/app/components/app_vaccinate_form_component.rb b/app/components/app_vaccinate_form_component.rb index 06dc19a26c..f5e0a46287 100644 --- a/app/components/app_vaccinate_form_component.rb +++ b/app/components/app_vaccinate_form_component.rb @@ -1,17 +1,17 @@ # frozen_string_literal: true class AppVaccinateFormComponent < ViewComponent::Base - def initialize(vaccinate_form) + def initialize(form) super - @vaccinate_form = vaccinate_form + @form = form end private - attr_reader :vaccinate_form + attr_reader :form - delegate :patient, :session, :programme, to: :vaccinate_form + delegate :patient, :session, :programme, to: :form delegate :academic_year, to: :session def url diff --git a/app/components/app_vaccination_record_summary_component.rb b/app/components/app_vaccination_record_summary_component.rb index 46bc5f9fcf..43268356f9 100644 --- a/app/components/app_vaccination_record_summary_component.rb +++ b/app/components/app_vaccination_record_summary_component.rb @@ -201,6 +201,20 @@ def call end end + if @vaccination_record.supplied_by.present? + summary_list.with_row do |row| + row.with_key { "Supplier" } + row.with_value { supplier_value } + if (href = @change_links[:supplier]) + row.with_action( + text: "Change", + visually_hidden_text: "supplier", + href: + ) + end + end + end + if @vaccination_record.performed_by.present? summary_list.with_row do |row| row.with_key { "Vaccinator" } @@ -331,6 +345,13 @@ def time_value ) end + def supplier_value + highlight_if( + @vaccination_record.supplied_by&.full_name, + @vaccination_record.supplied_by_user_id_changed? + ) + end + def vaccinator_value value = if @vaccination_record.performed_by == @current_user diff --git a/app/controllers/draft_vaccination_records_controller.rb b/app/controllers/draft_vaccination_records_controller.rb index ffdee3518b..215532a948 100644 --- a/app/controllers/draft_vaccination_records_controller.rb +++ b/app/controllers/draft_vaccination_records_controller.rb @@ -17,6 +17,7 @@ class DraftVaccinationRecordsController < ApplicationController before_action :validate_params, only: :update before_action :set_batches, if: -> { current_step == :batch } before_action :set_locations, if: -> { current_step == :location } + before_action :set_supplied_by_users, if: -> { current_step == :supplier } before_action :set_back_link_path after_action :verify_authorized @@ -165,7 +166,8 @@ def update_params ], location: %i[location_id], notes: %i[notes], - outcome: %i[outcome] + outcome: %i[outcome], + supplier: %i[supplied_by_user_id] }.fetch(current_step) params @@ -231,6 +233,10 @@ def set_locations @locations = policy_scope(Location).community_clinic end + def set_supplied_by_users + @supplied_by_users = current_team.users.show_in_suppliers + end + def set_back_link_path @back_link_path = if @draft_vaccination_record.editing? diff --git a/app/controllers/patient_sessions/vaccinations_controller.rb b/app/controllers/patient_sessions/vaccinations_controller.rb index 03b54e9750..762f859e0f 100644 --- a/app/controllers/patient_sessions/vaccinations_controller.rb +++ b/app/controllers/patient_sessions/vaccinations_controller.rb @@ -32,9 +32,10 @@ def create if @vaccinate_form.save(draft_vaccination_record:) steps = draft_vaccination_record.wizard_steps - steps.delete(:notes) # this is on the confirmation page - steps.delete(:identity) # this can only be changed from confirmation page steps.delete(:dose) # this can only be changed from confirmation page + steps.delete(:identity) # this can only be changed from confirmation page + steps.delete(:notes) # this is on the confirmation page + steps.delete(:supplier) # this can only be changed from confirmation page steps.delete(:date_and_time) steps.delete(:outcome) if draft_vaccination_record.administered? @@ -69,6 +70,7 @@ def vaccinate_form_params identity_check_confirmed_by_patient pre_screening_confirmed pre_screening_notes + supplied_by_user_id vaccine_id vaccine_method ] diff --git a/app/forms/vaccinate_form.rb b/app/forms/vaccinate_form.rb index c354de203b..4e97ba8ee0 100644 --- a/app/forms/vaccinate_form.rb +++ b/app/forms/vaccinate_form.rb @@ -19,6 +19,8 @@ class VaccinateForm attribute :pre_screening_confirmed, :boolean attribute :pre_screening_notes, :string + attribute :supplied_by_user_id, :integer + attribute :vaccine_method, :string attribute :delivery_site, :string attribute :dose_sequence, :integer @@ -37,10 +39,16 @@ class VaccinateForm maximum: 300 } - validates :vaccine_method, inclusion: { in: :vaccine_method_options } validates :pre_screening_notes, length: { maximum: 1000 } - validates :pre_screening_confirmed, presence: true, if: :administered? + + validates :supplied_by_user_id, + inclusion: { + in: :supplied_by_user_id_values + }, + if: :requires_supplied_by_user_id? + + validates :vaccine_method, inclusion: { in: :vaccine_method_options } validates :delivery_site, inclusion: { in: :delivery_site_options @@ -65,6 +73,12 @@ def delivery_site super end + def supplied_by_users + current_user.selected_team.users.show_in_suppliers + end + + def requires_supplied_by_user_id? = !current_user.show_in_suppliers + def save(draft_vaccination_record:) return nil if invalid? @@ -99,6 +113,7 @@ def save(draft_vaccination_record:) draft_vaccination_record.patient_id = patient.id draft_vaccination_record.performed_at = Time.current draft_vaccination_record.performed_by_user = current_user + draft_vaccination_record.supplied_by_user_id = supplied_by_user_id draft_vaccination_record.performed_ods_code = organisation.ods_code draft_vaccination_record.programme = programme draft_vaccination_record.session_id = session.id @@ -112,6 +127,8 @@ def save(draft_vaccination_record:) def administered? = vaccine_method != "none" + def supplied_by_user_id_values = supplied_by_users.pluck(:id) + def vaccine_method_options programme.vaccine_methods + ["none"] end diff --git a/app/models/draft_vaccination_record.rb b/app/models/draft_vaccination_record.rb index fefba2efc4..fb80ae67dc 100644 --- a/app/models/draft_vaccination_record.rb +++ b/app/models/draft_vaccination_record.rb @@ -11,8 +11,8 @@ class DraftVaccinationRecord attribute :delivery_method, :string attribute :delivery_site, :string attribute :dose_sequence, :integer + attribute :first_active_wizard_step, :string attribute :full_dose, :boolean - attribute :protocol, :string attribute :identity_check_confirmed_by_other_name, :string attribute :identity_check_confirmed_by_other_relationship, :string attribute :identity_check_confirmed_by_patient, :boolean @@ -27,8 +27,9 @@ class DraftVaccinationRecord attribute :performed_by_user_id, :integer attribute :performed_ods_code, :string attribute :programme_id, :integer + attribute :protocol, :string attribute :session_id, :integer - attribute :first_active_wizard_step, :string + attribute :supplied_by_user_id, :integer def initialize(current_user:, **attributes) @current_user = current_user @@ -47,6 +48,7 @@ def wizard_steps :notes, :date_and_time, (:outcome if can_change_outcome?), + (:supplier if requires_supplied_by?), (:delivery if administered?), (:dose if administered? && can_be_half_dose?), (:batch if administered?), @@ -198,6 +200,16 @@ def session=(value) self.session_id = value.id end + def supplied_by + return nil if supplied_by_user_id.nil? + + User.find(supplied_by_user_id) + end + + def supplied_by=(value) + self.supplied_by_user_id = value.id + end + def vaccination_record return nil if editing_id.nil? @@ -276,7 +288,6 @@ def writable_attribute_names delivery_site dose_sequence full_dose - protocol identity_check location_id location_name @@ -289,7 +300,9 @@ def writable_attribute_names performed_by_user_id performed_ods_code programme_id + protocol session_id + supplied_by_user_id vaccine_id ] end @@ -348,6 +361,10 @@ def can_change_outcome? outcome != "already_had" || editing? || session.nil? || session.today? end + def requires_supplied_by? + performed_by_user && !performed_by_user&.show_in_suppliers + end + def delivery_site_matches_delivery_method return if delivery_method.blank? diff --git a/app/models/user.rb b/app/models/user.rb index ce86d95667..5f6b4bcaef 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -72,6 +72,8 @@ class User < ApplicationRecord scope :recently_active, -> { where(last_sign_in_at: 1.week.ago..Time.current) } + scope :show_in_suppliers, -> { where(show_in_suppliers: true) } + enum :fallback_role, { nurse: 0, diff --git a/app/policies/vaccination_record_policy.rb b/app/policies/vaccination_record_policy.rb index ebe1c66d53..2f8cfd9ddb 100644 --- a/app/policies/vaccination_record_policy.rb +++ b/app/policies/vaccination_record_policy.rb @@ -17,7 +17,8 @@ def record_already_vaccinated? end def edit? - user.is_nurse? && record.recorded_in_service? && + (record.performed_by_user_id == user.id || user.is_nurse?) && + record.recorded_in_service? && record.performed_ods_code == user.selected_organisation.ods_code end diff --git a/app/views/draft_vaccination_records/confirm.html.erb b/app/views/draft_vaccination_records/confirm.html.erb index b47e62c642..55ef1f9b40 100644 --- a/app/views/draft_vaccination_records/confirm.html.erb +++ b/app/views/draft_vaccination_records/confirm.html.erb @@ -20,6 +20,7 @@ delivery_site: wizard_path("delivery"), dose_volume: @draft_vaccination_record.wizard_steps.include?(:dose) ? wizard_path("dose") : nil, identity: wizard_path("identity"), + supplier: wizard_path("supplier"), location: @draft_vaccination_record.wizard_steps.include?(:location) ? wizard_path("location") : nil, notes: wizard_path("notes"), outcome: @draft_vaccination_record.wizard_steps.include?(:outcome) ? wizard_path("outcome") : nil, diff --git a/app/views/draft_vaccination_records/supplier.html.erb b/app/views/draft_vaccination_records/supplier.html.erb new file mode 100644 index 0000000000..95727b5879 --- /dev/null +++ b/app/views/draft_vaccination_records/supplier.html.erb @@ -0,0 +1,22 @@ +<% content_for :before_main do %> + <%= govuk_back_link(href: @back_link_path) %> +<% end %> + +<% title = "Which nurse identified and pre-screened the child and supplied the vaccine?" %> +<% content_for :page_title, title %> + +<%= form_with model: @draft_vaccination_record, url: wizard_path, method: :put do |f| %> + <%= f.govuk_error_summary %> + + <%= f.govuk_radio_buttons_fieldset :supplied_by_user_id, + caption: { text: @patient.full_name, size: "l" }, + legend: { size: "l", tag: "h1", + text: title } do %> + + <% @supplied_by_users.each do |user| %> + <%= f.govuk_radio_button :supplied_by_user_id, user.id, label: { text: user.full_name } %> + <% end %> + <% end %> + + <%= f.govuk_submit "Continue" %> +<% end %> diff --git a/app/views/patient_sessions/programmes/show.html.erb b/app/views/patient_sessions/programmes/show.html.erb index 3a0b79ba8b..b1f5de97d0 100644 --- a/app/views/patient_sessions/programmes/show.html.erb +++ b/app/views/patient_sessions/programmes/show.html.erb @@ -10,7 +10,7 @@ <%= render AppPatientSessionTriageComponent.new(@patient_session, programme: @programme, triage_form: @triage_form) %> - <%= render AppPatientSessionRecordComponent.new(@patient_session, programme: @programme, vaccinate_form: @vaccinate_form) %> + <%= render AppPatientSessionRecordComponent.new(@patient_session, programme: @programme, current_user:, vaccinate_form: @vaccinate_form) %> <%= render AppPatientSessionOutcomeComponent.new(@patient_session, programme: @programme) %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 2d0a5dbc77..da53c92769 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -162,6 +162,8 @@ en: blank: Confirm you’ve checked the pre-screening statements are true pre_screening_notes: too_long: Enter notes that are less than %{count} characters long + supplied_by_user_id: + inclusion: Choose which nurse identified and pre-screened the child and supplied the vaccine vaccine_method: inclusion: Choose if they are ready to vaccinate vaccination_report: @@ -739,6 +741,7 @@ en: route: route school: school session: session + supplier: supplier timeline: timeline triage: triage type: type diff --git a/spec/components/app_patient_session_record_component_spec.rb b/spec/components/app_patient_session_record_component_spec.rb index 1eb5b27e25..b63caa08fd 100644 --- a/spec/components/app_patient_session_record_component_spec.rb +++ b/spec/components/app_patient_session_record_component_spec.rb @@ -7,10 +7,12 @@ described_class.new( patient_session, programme: programmes.first, + current_user:, vaccinate_form: VaccinateForm.new ) end + let(:current_user) { create(:user) } let(:programmes) { [create(:programme, :hpv)] } let(:session) { create(:session, :today, programmes:) } let(:patient) do diff --git a/spec/components/app_vaccinate_form_component_spec.rb b/spec/components/app_vaccinate_form_component_spec.rb index 40ef233d3a..c7eede75cb 100644 --- a/spec/components/app_vaccinate_form_component_spec.rb +++ b/spec/components/app_vaccinate_form_component_spec.rb @@ -5,8 +5,13 @@ let(:programme) { create(:programme) } let(:programmes) { [programme] } - let(:session) { create(:session, :today, programmes:) } + + let(:team) { create(:team, programmes:) } + let(:current_user) { create(:user, team:) } + + let(:session) { create(:session, :today, team:, programmes:) } let(:session_date) { session.session_dates.first } + let(:patient) do create( :patient, @@ -20,7 +25,7 @@ end let(:vaccinate_form) do - VaccinateForm.new(patient:, session_date:, programme:) + VaccinateForm.new(current_user:, patient:, session_date:, programme:) end let(:component) { described_class.new(vaccinate_form) } diff --git a/spec/factories/users.rb b/spec/factories/users.rb index dc57218e5c..b5d2703cc4 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -34,10 +34,10 @@ factory :user, aliases: %i[ nurse - assessor created_by - recorded_by performed_by + recorded_by + supplied_by uploaded_by ] do transient do @@ -61,6 +61,7 @@ sequence(:email) { |n| "nurse-#{n}@example.com" } fallback_role { :nurse } + show_in_suppliers { true } given_name { "Test" } family_name { "User" } @@ -83,12 +84,14 @@ sequence(:email) { |n| "admin-#{n}@example.com" } role_code { CIS2Info::ADMIN_ROLE } fallback_role { :admin } + show_in_suppliers { false } end trait :superuser do sequence(:email) { |n| "superuser-#{n}@example.com" } role_workgroups { [CIS2Info::SUPERUSER_WORKGROUP] } fallback_role { :superuser } + show_in_suppliers { false } end trait :healthcare_assistant do @@ -98,6 +101,7 @@ [CIS2Info::PERSONAL_MEDICATION_ADMINISTRATION_ACTIVITY_CODE] end fallback_role { :healthcare_assistant } + show_in_suppliers { false } end trait :prescriber do diff --git a/spec/features/flu_vaccination_hca_pgd_supply_spec.rb b/spec/features/flu_vaccination_hca_pgd_supply_spec.rb index 38755a9891..91613f5407 100644 --- a/spec/features/flu_vaccination_hca_pgd_supply_spec.rb +++ b/spec/features/flu_vaccination_hca_pgd_supply_spec.rb @@ -21,9 +21,20 @@ end def given_a_session_exists - programmes = [create(:programme, :flu)] + @programme = create(:programme, :flu) + programmes = [@programme] @team = create(:team, programmes:) + + @batch = + create( + :batch, + :not_expired, + team: @team, + vaccine: @programme.vaccines.nasal.first + ) + + @nurse = create(:nurse, team: @team) @user = create(:healthcare_assistant, team: @team) @session = @@ -78,11 +89,28 @@ def when_i_click_on_the_nasal_and_injection_patient def when_i_click_on_the_injection_patient # This patient won't be in the "Record" tab. + expect(page).not_to have_content(@patient_injection_only.full_name) + within(".app-secondary-navigation") { click_on "Children" } click_on @patient_injection_only.full_name end def then_i_am_able_to_vaccinate_them + check "I have checked that the above statements are true" + select @nurse.full_name + within all("section")[1] do + choose "Yes" + end + click_on "Continue" + + choose @batch.name + click_on "Continue" + + click_on "Change supplier" + choose @nurse.full_name + 4.times { click_on "Continue" } + + click_on "Confirm" click_on "Record vaccinations" end diff --git a/spec/forms/vaccinate_form_spec.rb b/spec/forms/vaccinate_form_spec.rb index e638da39fc..012b89465e 100644 --- a/spec/forms/vaccinate_form_spec.rb +++ b/spec/forms/vaccinate_form_spec.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true describe VaccinateForm do - subject(:form) { described_class.new(programme:) } + subject(:form) { described_class.new(programme:, current_user:) } let(:programme) { create(:programme) } + let(:current_user) { create(:user) } describe "validations" do it do @@ -28,7 +29,8 @@ subject(:form) do described_class.new( identity_check_confirmed_by_patient: false, - programme: + programme:, + current_user: ) end From 8634a32e06684f67f1b4982ceebf6170daf3c678 Mon Sep 17 00:00:00 2001 From: John Henderson Date: Wed, 20 Aug 2025 12:20:10 +0100 Subject: [PATCH 18/27] Basic functionality to add PSD to a triaged patient Features include: - Additional question presented in triage section if prescriber wishes to add a PSD so that a healthcare worker can do it. This option is only displayed if the user is a prescriber and the session has PSD enabled. - Backend updated to handle PSD creation - The tag "PSD added" shows up in the action list header on the patient page if it has a PSD --- .../app_triage_form_component.html.erb | 10 ++- app/components/app_triage_form_component.rb | 7 ++ .../patient_sessions/triages_controller.rb | 21 +++++- app/forms/triage_form.rb | 8 +++ app/models/patient.rb | 1 + app/models/patient_session.rb | 7 ++ app/views/patient_sessions/_header.html.erb | 6 ++ config/locales/en.yml | 3 + spec/features/triage_spec.rb | 68 ++++++++++++++++++- spec/forms/triage_form_spec.rb | 6 ++ 10 files changed, 134 insertions(+), 3 deletions(-) diff --git a/app/components/app_triage_form_component.html.erb b/app/components/app_triage_form_component.html.erb index d69912ce52..56799dacde 100644 --- a/app/components/app_triage_form_component.html.erb +++ b/app/components/app_triage_form_component.html.erb @@ -3,7 +3,15 @@ <%= f.govuk_radio_buttons_fieldset :status_and_vaccine_method, **fieldset_options do %> <% triage_form.safe_to_vaccinate_options.each do |option| %> - <%= f.govuk_radio_button :status_and_vaccine_method, option %> + <%= f.govuk_radio_button :status_and_vaccine_method, option do %> + <% if show_psd_options?(option) %> + <%= f.govuk_radio_buttons_fieldset :psd_action, + legend: { text: "Do you want to add a PSD?", size: "s" } do %> + <%= f.govuk_radio_button :add_psd, "true", label: { text: "Yes" } %> + <%= f.govuk_radio_button :add_psd, "false", label: { text: "No" } %> + <% end %> + <% end %> + <% end %> <% end %> <%= f.govuk_radio_divider %> <% triage_form.other_options.each do |option| %> diff --git a/app/components/app_triage_form_component.rb b/app/components/app_triage_form_component.rb index fa48c813cc..8fe093edb4 100644 --- a/app/components/app_triage_form_component.rb +++ b/app/components/app_triage_form_component.rb @@ -26,6 +26,13 @@ def initialize( def builder = GOVUKDesignSystemFormBuilder::FormBuilder + def show_psd_options?(option) + Flipper.enabled?(:delegation) && + helpers.policy(PatientSpecificDirection).create? && + patient_session.session.psd_enabled? && + option == "safe_to_vaccinate_nasal" + end + def fieldset_options text = "Is it safe to vaccinate #{patient.given_name}?" hint = diff --git a/app/controllers/patient_sessions/triages_controller.rb b/app/controllers/patient_sessions/triages_controller.rb index 1bb20d24cc..cde089ef07 100644 --- a/app/controllers/patient_sessions/triages_controller.rb +++ b/app/controllers/patient_sessions/triages_controller.rb @@ -46,6 +46,8 @@ def create ) .each { send_triage_confirmation(@patient_session, @programme, it) } + ensure_psd_exists if @triage_form.add_psd? + redirect_to redirect_path, flash: { success: "Triage outcome updated" } else render "patient_sessions/programmes/show", @@ -57,7 +59,7 @@ def create private def triage_form_params - params.expect(triage_form: %i[status_and_vaccine_method notes]) + params.expect(triage_form: %i[status_and_vaccine_method notes add_psd]) end def redirect_path @@ -68,4 +70,21 @@ def redirect_path return_to: "triage" ) end + + def ensure_psd_exists + psd_attributes = { + academic_year: @academic_year, + patient: @patient, + programme: @programme, + vaccine: @programme.vaccines.first, + vaccine_method: :nasal, + delivery_site: :nose + } + + return if PatientSpecificDirection.exists?(**psd_attributes) + + PatientSpecificDirection.create!( + psd_attributes.merge(created_by: current_user) + ) + end end diff --git a/app/forms/triage_form.rb b/app/forms/triage_form.rb index c891186a77..374aebed89 100644 --- a/app/forms/triage_form.rb +++ b/app/forms/triage_form.rb @@ -7,6 +7,7 @@ class TriageForm attr_accessor :patient_session, :programme, :current_user attribute :status_and_vaccine_method, :string + attribute :add_psd, :boolean attribute :notes, :string attribute :vaccine_methods, array: true, default: [] @@ -15,6 +16,13 @@ class TriageForm in: :status_and_vaccine_method_options } validates :notes, length: { maximum: 1000 } + validates :add_psd, + inclusion: { + in: [true, false] + }, + if: -> { add_psd.present? } + + def add_psd? = add_psd def triage=(triage) self.status_and_vaccine_method = diff --git a/app/models/patient.rb b/app/models/patient.rb index eac95b4e5b..d1eaf0d017 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -78,6 +78,7 @@ class Patient < ApplicationRecord has_many :triages has_many :vaccination_records, -> { kept } has_many :vaccination_statuses + has_many :patient_specific_directions has_many :gillick_assessments has_many :parents, through: :parent_relationships diff --git a/app/models/patient_session.rb b/app/models/patient_session.rb index 336f7aa464..f48ec87b56 100644 --- a/app/models/patient_session.rb +++ b/app/models/patient_session.rb @@ -268,6 +268,13 @@ class PatientSession < ApplicationRecord delegate :academic_year, to: :session + def psd_added?(programme:) + patient + .patient_specific_directions + .where(programme:, academic_year:) + .exists? + end + def safe_to_destroy? vaccination_records.empty? && gillick_assessments.empty? && session_attendances.none?(&:attending?) diff --git a/app/views/patient_sessions/_header.html.erb b/app/views/patient_sessions/_header.html.erb index 471c6ba2c5..132d2fa14b 100644 --- a/app/views/patient_sessions/_header.html.erb +++ b/app/views/patient_sessions/_header.html.erb @@ -29,6 +29,12 @@
      <% if (session_attendance = @patient_session.todays_attendance) %> + <% if @patient_session.psd_added?(programme: @programme) %> +
    • + PSD added +
    • + <% end %> +
    • <%= render AppRegisterStatusTagComponent.new(@patient_session.registration_status&.status || "unknown") %>
    • diff --git a/config/locales/en.yml b/config/locales/en.yml index da53c92769..050e424058 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -146,6 +146,9 @@ en: status_and_vaccine_method: blank: Choose a status inclusion: Choose a status + add_psd: + blank: Select yes or no + inclusion: Select yes or no vaccinate_form: attributes: delivery_site: diff --git a/spec/features/triage_spec.rb b/spec/features/triage_spec.rb index f9b88e7d00..1ddfa878d0 100644 --- a/spec/features/triage_spec.rb +++ b/spec/features/triage_spec.rb @@ -72,6 +72,23 @@ and_the_vaccine_method_is_recorded_as_nasal end + scenario "adding PSD instruction" do + given_delegation_feature_flag_is_enabled + and_a_flu_programme_with_a_running_session_with_psd_enabled + and_a_patient_with_nasal_consent_who_needs_triage_exists + and_i_am_signed_in + + when_i_go_to_the_session_triage_tab + then_i_see_the_patient_who_needs_triage + + when_i_go_to_the_patient_that_needs_triage + and_i_choose_that_they_are_safe_to_vaccinate_with_nasal + and_i_choose_to_add_psd + and_i_save_triage + + then_i_should_see_the_patient_tagged_psd_added + end + def given_a_programme_with_a_running_session programmes = [create(:programme, :hpv)] @team = create(:team, :with_one_nurse, programmes:) @@ -92,6 +109,16 @@ def given_a_flu_programme_with_a_running_session @session = create(:session, team: @team, programmes:) end + def and_a_flu_programme_with_a_running_session_with_psd_enabled + programmes = [create(:programme, :flu)] + @team = create(:team, :with_one_nurse, programmes:) + + @batch = + create(:batch, team: @team, vaccine: programmes.first.vaccines.first) + + @session = create(:session, :psd_enabled, team: @team, programmes:) + end + def and_a_patient_who_needs_triage_exists @patient_triage_needed = create( @@ -112,6 +139,17 @@ def and_a_patient_who_needs_triage_exists @patient_triage_needed.reload # Make sure both consents are accessible end + def and_a_patient_with_nasal_consent_who_needs_triage_exists + @patient_triage_needed = + create( + :patient_session, + :consent_given_nasal_only_triage_needed, + session: @session + ).patient + + @patient_triage_needed.reload # Make sure both consents are accessible + end + def and_patients_with_different_flu_consent_types_exist @patient_injection_only = create( @@ -140,6 +178,10 @@ def and_a_patient_who_doesnt_need_triage_exists ).patient end + def given_delegation_feature_flag_is_enabled + Flipper.enable(:delegation) + end + def and_i_am_signed_in @user = @team.users.first sign_in @user @@ -149,6 +191,10 @@ def when_i_go_to_the_session_triage_tab visit session_triage_path(@session) end + def when_i_visit_the_register_tab + visit session_register_path(@session) + end + def then_i_see_the_patient_who_needs_triage expect(page).to have_content(@patient_triage_needed.full_name) end @@ -172,6 +218,10 @@ def then_i_see_the_triage_options expect(page).to have_selector :heading, "Is it safe to vaccinate" end + def and_i_save_triage + click_button "Save triage" + end + def when_i_record_that_they_need_triage choose "No, keep in triage" click_button "Save triage" @@ -194,10 +244,18 @@ def when_i_record_that_they_are_safe_to_vaccinate_with_injection end def and_i_record_that_they_are_safe_to_vaccinate_with_nasal - choose "Yes, it’s safe to vaccinate with nasal spray" + and_i_choose_that_they_are_safe_to_vaccinate_with_nasal click_button "Save triage" end + def and_i_choose_that_they_are_safe_to_vaccinate_with_nasal + choose "Yes, it’s safe to vaccinate with nasal spray" + end + + def and_i_choose_to_add_psd + choose "Yes" + end + def when_i_do_not_vaccinate choose "No, do not vaccinate" click_button "Save triage" @@ -324,4 +382,12 @@ def and_the_vaccine_method_is_recorded_as_nasal triage = @patient_nasal_only.triages.last expect(triage.vaccine_method).to eq("nasal") end + + def then_i_should_see_the_patient_tagged_psd_added + within(".app-action-list") { expect(page).to have_content("PSD added") } + end + + def then_i_should_see_the_patient_with_status_psd_added + expect(page).to have_content("PSD added") + end end diff --git a/spec/forms/triage_form_spec.rb b/spec/forms/triage_form_spec.rb index fc8477f477..28ea502cd6 100644 --- a/spec/forms/triage_form_spec.rb +++ b/spec/forms/triage_form_spec.rb @@ -16,6 +16,12 @@ it { should_not validate_presence_of(:notes) } it { should_not validate_presence_of(:vaccine_methods) } it { should validate_length_of(:notes).is_at_most(1000) } + + it do + expect(form).to validate_inclusion_of(:add_psd).in_array( + [true, false] + ).allow_nil + end end describe "when the patient is safe to vaccinate for HPV" do From abc8da0391fc4485a12462537a52cad8297f3ac2 Mon Sep 17 00:00:00 2001 From: John Henderson Date: Tue, 26 Aug 2025 11:31:42 +0100 Subject: [PATCH 19/27] Refactor AppRegisterStatusTagComponent to be reusable The upcoming PSDs tab page will need to render the PSD status for each patient. The html markup will be the same as what this component renders for the register status. --- ...nt_session_search_result_card_component.rb | 5 +++-- .../app_register_status_tag_component.rb | 21 ------------------ app/components/app_status_tag_component.rb | 22 +++++++++++++++++++ app/views/patient_sessions/_header.html.erb | 2 +- 4 files changed, 26 insertions(+), 24 deletions(-) delete mode 100644 app/components/app_register_status_tag_component.rb create mode 100644 app/components/app_status_tag_component.rb 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 c468368432..d70206ac98 100644 --- a/app/components/app_patient_session_search_result_card_component.rb +++ b/app/components/app_patient_session_search_result_card_component.rb @@ -203,8 +203,9 @@ def register_status_tag key: :register, value: render( - AppRegisterStatusTagComponent.new( - patient_session.registration_status&.status || "unknown" + AppStatusTagComponent.new( + patient_session.registration_status&.status || "unknown", + context: :register ) ) } diff --git a/app/components/app_register_status_tag_component.rb b/app/components/app_register_status_tag_component.rb deleted file mode 100644 index 5e64d859a0..0000000000 --- a/app/components/app_register_status_tag_component.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -class AppRegisterStatusTagComponent < ViewComponent::Base - def initialize(status) - super - - @status = status - end - - def call = tag.strong(text, class: ["nhsuk-tag nhsuk-tag--#{colour}"]) - - private - - def text - I18n.t(@status, scope: %i[status register label]) - end - - def colour - I18n.t(@status, scope: %i[status register colour]) - end -end diff --git a/app/components/app_status_tag_component.rb b/app/components/app_status_tag_component.rb new file mode 100644 index 0000000000..80c6a59357 --- /dev/null +++ b/app/components/app_status_tag_component.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class AppStatusTagComponent < ViewComponent::Base + def initialize(status, context:) + super + + @status = status + @context = context + end + + def call = tag.strong(text, class: ["nhsuk-tag nhsuk-tag--#{colour}"]) + + private + + def text + I18n.t(@status, scope: [:status, @context, :label]) + end + + def colour + I18n.t(@status, scope: [:status, @context, :colour]) + end +end diff --git a/app/views/patient_sessions/_header.html.erb b/app/views/patient_sessions/_header.html.erb index 471c6ba2c5..3523e64aa1 100644 --- a/app/views/patient_sessions/_header.html.erb +++ b/app/views/patient_sessions/_header.html.erb @@ -30,7 +30,7 @@
        <% if (session_attendance = @patient_session.todays_attendance) %>
      • - <%= render AppRegisterStatusTagComponent.new(@patient_session.registration_status&.status || "unknown") %> + <%= render AppStatusTagComponent.new(@patient_session.registration_status&.status || "unknown", context: :register) %>
      • <% if policy(session_attendance).edit? && @session.requires_registration? %> From 141701e457ac551b66e8f44fab63572472fdaef5 Mon Sep 17 00:00:00 2001 From: John Henderson Date: Fri, 22 Aug 2025 09:40:28 +0100 Subject: [PATCH 20/27] Prescribers can create PSD instructions in bulk This PR implements a new feature that allows prescribers (nurses, pharmacists) to bulk create Patient Specific Directions (PSDs) for patients who have given consent and don't require individual triage assessment. This significantly reduces manual work by enabling batch processing of PSDs instead of creating them one by one. What's changed? - New (basic) controller and page for the PSDs tab (no filters yet) - PSDs tab only appears if delegation feature is enabled - New controller to manage the bulk add PSDs --- .../stylesheets/components/_action-list.scss | 56 +++++++++ ...nt_session_search_result_card_component.rb | 34 +++++- .../app_search_results_component.rb | 7 +- .../patient_specific_directions_controller.rb | 72 ++++++++++++ app/models/patient_session.rb | 12 ++ app/views/sessions/_header.html.erb | 8 ++ .../bulk_add.html.erb | 22 ++++ .../patient_specific_directions/show.html.erb | 41 +++++++ config/locales/en.yml | 1 + config/locales/status.en.yml | 8 ++ config/routes.rb | 5 + spec/factories/teams.rb | 4 + .../patient_specific_directions_spec.rb | 106 ++++++++++++++++++ 13 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 app/controllers/sessions/patient_specific_directions_controller.rb create mode 100644 app/views/sessions/patient_specific_directions/bulk_add.html.erb create mode 100644 app/views/sessions/patient_specific_directions/show.html.erb create mode 100644 spec/features/patient_specific_directions_spec.rb diff --git a/app/assets/stylesheets/components/_action-list.scss b/app/assets/stylesheets/components/_action-list.scss index e803d575d5..fc20935bde 100644 --- a/app/assets/stylesheets/components/_action-list.scss +++ b/app/assets/stylesheets/components/_action-list.scss @@ -33,3 +33,59 @@ margin-right: 0; padding-right: 0; } + +.nhsuk-action-link { + @include nhsuk-responsive-margin(6, "bottom"); +} + +.nhsuk-action-link__link { + display: inline-block; // [1] + padding-left: 38px; // [2] + position: relative; // [3] + text-decoration: none; // [4] + + @include nhsuk-font(22, $weight: bold); + + &:not(:focus):hover { + .nhsuk-action-link__text { + text-decoration: underline; // [6] + } + } + + @include nhsuk-media-query($until: tablet) { + padding-left: 26px; // [2] + } + + @include nhsuk-media-query($media-type: print) { + color: $nhsuk-print-text-color; + + &:visited { + color: $nhsuk-print-text-color; + } + } + + .nhsuk-icon__arrow-right-circle { + // stylelint-disable-next-line declaration-no-important + fill: $color_nhsuk-green !important; + height: 36px; + left: -3px; + position: absolute; + top: -3px; + width: 36px; + + @include nhsuk-print-color($nhsuk-print-text-color); + + @include nhsuk-media-query($until: tablet) { + height: 24px; + left: -2px; + margin-bottom: 0; + top: 1px; + width: 24px; + } + } + + &:focus .nhsuk-icon__arrow-right-circle { + // stylelint-disable-next-line declaration-no-important + fill: $color_nhsuk-black !important; + } +} 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 d70206ac98..960268de68 100644 --- a/app/components/app_patient_session_search_result_card_component.rb +++ b/app/components/app_patient_session_search_result_card_component.rb @@ -37,7 +37,7 @@ class AppPatientSessionSearchResultCardComponent < ViewComponent::Base end end - if (note = patient_session.latest_note) + if context != :patient_specific_direction && (note = patient_session.latest_note) summary_list.with_row do |row| row.with_key { "Notes" } row.with_value { render note_to_log_event(note) } @@ -57,7 +57,16 @@ class AppPatientSessionSearchResultCardComponent < ViewComponent::Base def initialize(patient_session, context:, programmes: []) super - unless context.in?(%i[patients consent triage register record]) + unless context.in?( + %i[ + patients + consent + triage + register + record + patient_specific_direction + ] + ) raise "Unknown context: #{context}" end @@ -157,6 +166,8 @@ def status_tags [consent_status_tag] when :triage [triage_status_tag] + when :patient_specific_direction + [patient_specific_direction_status_tag] else [vaccination_status_tag] end @@ -241,6 +252,19 @@ def triage_status_value(triage_status, programme) { status: status } end + def patient_specific_direction_status_tag + { + key: :patient_specific_direction, + value: + render( + AppStatusTagComponent.new( + psd_exists?(programmes.first) ? :added : :not_added, + context: :patient_specific_direction + ) + ) + } + end + def note_to_log_event(note) truncated_body = note.body.truncate_words(80, omission: "…") @@ -261,4 +285,10 @@ def note_to_log_event(note) AppLogEventComponent.new(body:, at: note.created_at, by: note.created_by) end + + def psd_exists?(programme) + patient.patient_specific_directions.any? do + it.programme_id == programme.id && it.academic_year == academic_year + end + end end diff --git a/app/components/app_search_results_component.rb b/app/components/app_search_results_component.rb index f68a3d004e..e87016dbb2 100644 --- a/app/components/app_search_results_component.rb +++ b/app/components/app_search_results_component.rb @@ -2,7 +2,7 @@ class AppSearchResultsComponent < ViewComponent::Base erb_template <<-ERB -

        Search results

        +

        <%= heading %>

        <% if has_results? %> @@ -19,16 +19,17 @@ class AppSearchResultsComponent < ViewComponent::Base <% end %> ERB - def initialize(pagy, label:) + def initialize(pagy, label:, heading: "Search results") super @pagy = pagy @label = label + @heading = heading end private - attr_reader :pagy, :label + attr_reader :pagy, :label, :heading def has_results? = pagy.count.positive? end diff --git a/app/controllers/sessions/patient_specific_directions_controller.rb b/app/controllers/sessions/patient_specific_directions_controller.rb new file mode 100644 index 0000000000..df66c2c91f --- /dev/null +++ b/app/controllers/sessions/patient_specific_directions_controller.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +class Sessions::PatientSpecificDirectionsController < ApplicationController + include PatientSearchFormConcern + + before_action :set_session + before_action :set_patient_search_form + + layout "full" + + def show + scope = + @session.patient_sessions.includes_programmes.includes( + patient: { + patient_specific_directions: :programme + } + ) + @eligible_for_bulk_psd_count = patient_sessions_allowed_psd.count + patient_sessions = @form.apply(scope) + @pagy, @patient_sessions = pagy(patient_sessions) + end + + def create + ActiveRecord::Base.transaction do + PatientSpecificDirection.import!( + psds_to_create, + on_duplicate_key_ignore: true + ) + end + + redirect_to session_patient_specific_directions_path(@session), + flash: { + success: "PSDs added" + } + end + + def bulk_add + @eligible_for_bulk_psd_count = patient_sessions_allowed_psd.count + end + + private + + def set_session + @session = policy_scope(Session).find_by!(slug: params[:session_slug]) + end + + def programme + @session.programmes.includes(:vaccines).first + end + + def psds_to_create + patient_sessions_allowed_psd.map do |patient_session| + PatientSpecificDirection.new( + academic_year: @session.academic_year, + patient_id: patient_session.patient_id, + programme_id: programme.id, + vaccine_id: programme.vaccines.first.id, + created_by_user_id: current_user.id, + vaccine_method: :nasal, + delivery_site: :nose + ) + end + end + + def patient_sessions_allowed_psd + @patient_sessions_allowed_psd ||= + @session + .patient_sessions + .has_consent_status(:given, programme:) + .without_patient_specific_direction(programme:) + end +end diff --git a/app/models/patient_session.rb b/app/models/patient_session.rb index 336f7aa464..3cb3ad86c8 100644 --- a/app/models/patient_session.rb +++ b/app/models/patient_session.rb @@ -257,6 +257,18 @@ class PatientSession < ApplicationRecord end end + scope :without_patient_specific_direction, + ->(programme:) do + joins(:session).where.not( + PatientSpecificDirection + .where("patient_id = patient_sessions.patient_id") + .where("academic_year = sessions.academic_year") + .where(programme:) + .arel + .exists + ) + end + scope :destroy_all_if_safe, -> do includes( diff --git a/app/views/sessions/_header.html.erb b/app/views/sessions/_header.html.erb index 5a20fa2854..e181adbf5c 100644 --- a/app/views/sessions/_header.html.erb +++ b/app/views/sessions/_header.html.erb @@ -24,6 +24,14 @@ selected: request.path == session_consent_path(@session), ) + if @session.psd_enabled? + nav.with_item( + href: session_patient_specific_directions_path(@session), + text: t("sessions.tabs.patient_specific_directions"), + selected: request.path == session_patient_specific_directions_path(@session), + ) + end + nav.with_item( href: session_triage_path(@session), text: t("sessions.tabs.triage"), diff --git a/app/views/sessions/patient_specific_directions/bulk_add.html.erb b/app/views/sessions/patient_specific_directions/bulk_add.html.erb new file mode 100644 index 0000000000..3d75be1f81 --- /dev/null +++ b/app/views/sessions/patient_specific_directions/bulk_add.html.erb @@ -0,0 +1,22 @@ +<% content_for :before_main do %> + <%= render AppBreadcrumbComponent.new(items: [ + { text: t("dashboard.index.title"), href: dashboard_path }, + { text: t("sessions.index.title"), href: sessions_path }, + { text: @session.location.name, href: session_path(@session) }, + ]) %> +<% end %> + +<%= h1 "Are you sure you want to add #{@eligible_for_bulk_psd_count} new PSDs?" %> + +

        +
        +

        This cannot be undone.

        +
        + <%= govuk_button_to "Yes, add PSDs", session_patient_specific_directions_path(@session) %> + <%= govuk_link_to "No, return to session", + bulk_add_session_patient_specific_directions_path(@session), + method: :post, + prevent_double_click: true %> +
        +
        +
        \ No newline at end of file diff --git a/app/views/sessions/patient_specific_directions/show.html.erb b/app/views/sessions/patient_specific_directions/show.html.erb new file mode 100644 index 0000000000..6dd58dff39 --- /dev/null +++ b/app/views/sessions/patient_specific_directions/show.html.erb @@ -0,0 +1,41 @@ +<% content_for :before_main do %> + <%= render AppBreadcrumbComponent.new(items: [ + { text: t("dashboard.index.title"), href: dashboard_path }, + { text: t("sessions.index.title"), href: sessions_path }, + { text: @session.location.name, href: session_path(@session) }, + ]) %> +<% end %> + +<%= render "sessions/header" %> + +<%= govuk_inset_text do %> + Information: +

        + There are <%= @eligible_for_bulk_psd_count %> children with consent for the nasal flu vaccine + who do not require triage and do not yet have a PSD in place. +

        + <% if current_user.is_nurse? %> + + <% end %> +<% end %> + +
        +
        + +
        + <%= render AppSessionNeedsReviewWarningComponent.new(session: @session) %> + <%= render AppSearchResultsComponent.new(@pagy, label: "children", heading: "Review PSDs") do %> + <% @patient_sessions.each do |patient_session| %> + <%= render AppPatientSessionSearchResultCardComponent.new(patient_session, context: :patient_specific_direction) %> + <% end %> + <% end %> +
        +
        diff --git a/config/locales/en.yml b/config/locales/en.yml index da53c92769..5a6c72cb1f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -687,6 +687,7 @@ en: record: Record vaccinations register: Register triage: Triage + patient_specific_directions: PSDs table: no_filtered_results: We couldn’t find any children that matched your filters. no_results: No results diff --git a/config/locales/status.en.yml b/config/locales/status.en.yml index c9c7f3f059..24a83e772c 100644 --- a/config/locales/status.en.yml +++ b/config/locales/status.en.yml @@ -5,6 +5,7 @@ en: register: Registration status triage: Triage status vaccination: Outcome + patient_specific_direction: PSD status consent: label: conflicts: Conflicting consent @@ -74,3 +75,10 @@ en: could_not_vaccinate: red none_yet: white vaccinated: green + patient_specific_direction: + label: + added: PSD added + not_added: PSD not added + colour: + added: green + not_added: grey \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index f6b1d37cde..69917c5e40 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -215,6 +215,11 @@ resource :patients, only: :show, controller: "sessions/patients" resource :consent, only: :show, controller: "sessions/consent" resource :triage, only: :show, controller: "sessions/triage" + resource :patient_specific_directions, + only: %i[show create], + controller: "sessions/patient_specific_directions" do + get "bulk-add", action: :bulk_add + end resource :register, only: :show, controller: "sessions/register" do post ":patient_id/:status", as: :create, action: :create end diff --git a/spec/factories/teams.rb b/spec/factories/teams.rb index 92c8c12be5..ae06bcc347 100644 --- a/spec/factories/teams.rb +++ b/spec/factories/teams.rb @@ -56,6 +56,10 @@ users { [create(:user, :admin, team: instance)] } end + trait :with_one_healthcare_assistant do + users { [create(:user, :healthcare_assistant, team: instance)] } + end + trait :with_generic_clinic do after(:create) { |team| GenericClinicFactory.call(team:) } end diff --git a/spec/features/patient_specific_directions_spec.rb b/spec/features/patient_specific_directions_spec.rb new file mode 100644 index 0000000000..232cc6fd06 --- /dev/null +++ b/spec/features/patient_specific_directions_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +describe "Patient Specific Directions" do + before { given_delegation_feature_flag_is_enabled } + + scenario "prescriber can bulk add PSDs to patients that don't require triage" do + given_a_flu_programme_with_a_running_session(user_type: :with_one_nurse) + and_a_patient_who_doesnt_need_triage_exists + and_i_am_signed_in + + when_i_go_to_the_session_psds_tab + then_the_patient_should_have_psd_status_not_added + and_i_should_see_one_child_eligible_for_psd + + when_i_click_add_new_psds + and_should_see_again_one_child_eligible_for_psd + + when_i_click_on_button_to_bulk_add_psds + then_the_patient_should_have_psd_status_added + and_zero_children_should_be_eligible_for_psd + end + + scenario "admin cannot bulk add PSDs to patients" do + given_a_flu_programme_with_a_running_session(user_type: :with_one_admin) + and_a_patient_who_doesnt_need_triage_exists + and_i_am_signed_in(role: :admin) + + when_i_go_to_the_session_psds_tab + then_i_should_not_see_link_to_bulk_add_psds + end + + scenario "healthcare assistant cannot bulk add PSDs to patients" do + given_a_flu_programme_with_a_running_session( + user_type: :with_one_healthcare_assistant + ) + and_a_patient_who_doesnt_need_triage_exists + and_i_am_signed_in(role: :healthcare_assistant) + + when_i_go_to_the_session_psds_tab + then_i_should_not_see_link_to_bulk_add_psds + end + + def given_delegation_feature_flag_is_enabled + Flipper.enable(:delegation) + end + + def given_a_flu_programme_with_a_running_session(user_type:) + programmes = [create(:programme, :flu)] + @team = create(:team, user_type, programmes:) + + @batch = + create(:batch, team: @team, vaccine: programmes.first.vaccines.first) + + @session = create(:session, team: @team, programmes:) + end + + def and_a_patient_who_doesnt_need_triage_exists + @patient_triage_not_needed = + create( + :patient_session, + :consent_given_triage_not_needed, + session: @session + ).patient + end + + def and_i_am_signed_in(role: :nurse) + @user = @team.users.first + sign_in @user, role: + end + + def when_i_go_to_the_session_psds_tab + visit session_patient_specific_directions_path(@session) + end + + def then_the_patient_should_have_psd_status_not_added + expect(page).to have_text("PSD not added") + end + + def then_the_patient_should_have_psd_status_added + expect(page).to have_text("PSD added") + end + + def and_i_should_see_one_child_eligible_for_psd + expect(page).to have_text("There are 1 children") + end + + def and_should_see_again_one_child_eligible_for_psd + expect(page).to have_text("1 new PSDs?") + end + + def and_zero_children_should_be_eligible_for_psd + expect(page).to have_text("There are 0 children") + end + + def when_i_click_add_new_psds + click_link "Add new PSDs" + end + + def when_i_click_on_button_to_bulk_add_psds + click_button "Yes, add PSDs" + end + + def then_i_should_not_see_link_to_bulk_add_psds + expect(page).not_to have_text("Add new PSDs") + end +end From 90cb4d48a582cd440bca7304b4c923b5a48b7946 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 27 Aug 2025 15:39:36 +0100 Subject: [PATCH 21/27] Small code improvements to PSD functionality This makes a number of small tweaks to the code related to PSD functionality to tidy things up, there should be no user-facing changes. --- app/components/app_triage_form_component.rb | 7 +++---- .../sessions/patient_specific_directions_controller.rb | 10 ++++------ config/routes.rb | 1 + 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/app/components/app_triage_form_component.rb b/app/components/app_triage_form_component.rb index 8fe093edb4..42bfd69cbd 100644 --- a/app/components/app_triage_form_component.rb +++ b/app/components/app_triage_form_component.rb @@ -27,10 +27,9 @@ def initialize( def builder = GOVUKDesignSystemFormBuilder::FormBuilder def show_psd_options?(option) - Flipper.enabled?(:delegation) && - helpers.policy(PatientSpecificDirection).create? && - patient_session.session.psd_enabled? && - option == "safe_to_vaccinate_nasal" + patient_session.session.psd_enabled? && + option == "safe_to_vaccinate_nasal" && + helpers.policy(PatientSpecificDirection).create? end def fieldset_options diff --git a/app/controllers/sessions/patient_specific_directions_controller.rb b/app/controllers/sessions/patient_specific_directions_controller.rb index df66c2c91f..2adc92df7d 100644 --- a/app/controllers/sessions/patient_specific_directions_controller.rb +++ b/app/controllers/sessions/patient_specific_directions_controller.rb @@ -21,12 +21,10 @@ def show end def create - ActiveRecord::Base.transaction do - PatientSpecificDirection.import!( - psds_to_create, - on_duplicate_key_ignore: true - ) - end + PatientSpecificDirection.import!( + psds_to_create, + on_duplicate_key_ignore: true + ) redirect_to session_patient_specific_directions_path(@session), flash: { diff --git a/config/routes.rb b/config/routes.rb index 69917c5e40..9f6d959ab1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -216,6 +216,7 @@ resource :consent, only: :show, controller: "sessions/consent" resource :triage, only: :show, controller: "sessions/triage" resource :patient_specific_directions, + path: "patient-specific-directions", only: %i[show create], controller: "sessions/patient_specific_directions" do get "bulk-add", action: :bulk_add From 0776664a5d14d7195a735f2c6a21a7accd3edb61 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 28 Aug 2025 08:37:09 +0100 Subject: [PATCH 22/27] Rename `bulk_add` action This renames it to `new` to match the Rails convention of a `new` and `create` action. --- .../sessions/patient_specific_directions_controller.rb | 8 ++++---- .../{bulk_add.html.erb => new.html.erb} | 4 ++-- .../sessions/patient_specific_directions/show.html.erb | 2 +- config/routes.rb | 6 ++---- 4 files changed, 9 insertions(+), 11 deletions(-) rename app/views/sessions/patient_specific_directions/{bulk_add.html.erb => new.html.erb} (91%) diff --git a/app/controllers/sessions/patient_specific_directions_controller.rb b/app/controllers/sessions/patient_specific_directions_controller.rb index 2adc92df7d..c0fd1bfcf0 100644 --- a/app/controllers/sessions/patient_specific_directions_controller.rb +++ b/app/controllers/sessions/patient_specific_directions_controller.rb @@ -20,6 +20,10 @@ def show @pagy, @patient_sessions = pagy(patient_sessions) end + def new + @eligible_for_bulk_psd_count = patient_sessions_allowed_psd.count + end + def create PatientSpecificDirection.import!( psds_to_create, @@ -32,10 +36,6 @@ def create } end - def bulk_add - @eligible_for_bulk_psd_count = patient_sessions_allowed_psd.count - end - private def set_session diff --git a/app/views/sessions/patient_specific_directions/bulk_add.html.erb b/app/views/sessions/patient_specific_directions/new.html.erb similarity index 91% rename from app/views/sessions/patient_specific_directions/bulk_add.html.erb rename to app/views/sessions/patient_specific_directions/new.html.erb index 3d75be1f81..35ee48c52b 100644 --- a/app/views/sessions/patient_specific_directions/bulk_add.html.erb +++ b/app/views/sessions/patient_specific_directions/new.html.erb @@ -14,9 +14,9 @@
        <%= govuk_button_to "Yes, add PSDs", session_patient_specific_directions_path(@session) %> <%= govuk_link_to "No, return to session", - bulk_add_session_patient_specific_directions_path(@session), + new_session_patient_specific_directions_path(@session), method: :post, prevent_double_click: true %>
        - \ No newline at end of file + diff --git a/app/views/sessions/patient_specific_directions/show.html.erb b/app/views/sessions/patient_specific_directions/show.html.erb index 6dd58dff39..c73abcf084 100644 --- a/app/views/sessions/patient_specific_directions/show.html.erb +++ b/app/views/sessions/patient_specific_directions/show.html.erb @@ -16,7 +16,7 @@

        <% if current_user.is_nurse? %>