diff --git a/.github/workflows/build-and-push-image.yml b/.github/workflows/build-and-push-image.yml
index f5977c3f26..352f13232a 100644
--- a/.github/workflows/build-and-push-image.yml
+++ b/.github/workflows/build-and-push-image.yml
@@ -105,7 +105,7 @@ jobs:
aws-role: ${{ fromJSON(needs.define-matrix.outputs.aws-roles) }}
steps:
- name: Download Docker image
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v5
with:
name: image
- name: Configure AWS Credentials
diff --git a/.github/workflows/data-replication-pipeline.yml b/.github/workflows/data-replication-pipeline.yml
index ec4e03d697..e714ead972 100644
--- a/.github/workflows/data-replication-pipeline.yml
+++ b/.github/workflows/data-replication-pipeline.yml
@@ -197,7 +197,7 @@ jobs:
role-to-assume: ${{ env.aws_role }}
aws-region: eu-west-2
- name: Download artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v5
with:
name: tfplan_infrastructure-${{ inputs.environment }}
path: ${{ runner.temp }}
diff --git a/.github/workflows/deploy-application.yml b/.github/workflows/deploy-application.yml
index 8844cbc6e3..93ec204704 100644
--- a/.github/workflows/deploy-application.yml
+++ b/.github/workflows/deploy-application.yml
@@ -99,7 +99,7 @@ jobs:
id-token: write
steps:
- name: Download artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v5
with:
name: DEPLOYMENT_ENVS-${{ inputs.environment }}
path: ${{ runner.temp }}
@@ -132,7 +132,7 @@ jobs:
id-token: write
steps:
- name: Download Artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v5
with:
name: DEPLOYMENT_ENVS-${{ inputs.environment }}
path: ${{ runner.temp }}
diff --git a/.github/workflows/deploy-backup-infrastructure.yml b/.github/workflows/deploy-backup-infrastructure.yml
index 300380449e..b7be8b77ee 100644
--- a/.github/workflows/deploy-backup-infrastructure.yml
+++ b/.github/workflows/deploy-backup-infrastructure.yml
@@ -80,7 +80,7 @@ jobs:
role-to-assume: ${{ env.aws_role }}
aws-region: eu-west-2
- name: Download artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v5
with:
name: tfplan_infrastructure-${{ inputs.environment }}
path: ${{ runner.temp }}
diff --git a/.github/workflows/deploy-infrastructure.yml b/.github/workflows/deploy-infrastructure.yml
index 552a48779f..4948f6f85a 100644
--- a/.github/workflows/deploy-infrastructure.yml
+++ b/.github/workflows/deploy-infrastructure.yml
@@ -111,7 +111,7 @@ jobs:
role-to-assume: ${{ env.aws_role }}
aws-region: eu-west-2
- name: Download artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v5
with:
name: tfplan_infrastructure-${{ inputs.environment }}
path: ${{ runner.temp }}
diff --git a/.github/workflows/deploy-monitoring.yml b/.github/workflows/deploy-monitoring.yml
index e15ded9d4a..836fdd15ff 100644
--- a/.github/workflows/deploy-monitoring.yml
+++ b/.github/workflows/deploy-monitoring.yml
@@ -103,7 +103,7 @@ jobs:
role-to-assume: ${{ env.aws_role }}
aws-region: eu-west-2
- name: Download AWS plan artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v5
with:
name: tfplan_monitoring_aws-${{ inputs.environment }}
path: ${{ runner.temp }}
diff --git a/Gemfile b/Gemfile
index 6bc20cda80..73c199a8d6 100644
--- a/Gemfile
+++ b/Gemfile
@@ -61,6 +61,7 @@ gem "rubyzip"
gem "sentry-rails"
gem "sentry-ruby"
gem "splunk-sdk-ruby"
+gem "table_tennis"
gem "tzinfo-data", platforms: %i[mingw mswin x64_mingw jruby]
gem "uk_postcode"
gem "wicked"
diff --git a/Gemfile.lock b/Gemfile.lock
index 488d862971..6b98d76cf9 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -367,6 +367,7 @@ GEM
rubyntlm (~> 0.6, >= 0.6.3)
webrick (~> 1.7)
webrobots (~> 0.1.2)
+ memo_wise (1.13.0)
method_source (1.1.0)
mime-types (3.6.0)
logger
@@ -425,6 +426,7 @@ GEM
orm_adapter (0.5.0)
ostruct (0.6.2)
pagy (9.3.5)
+ paint (2.3.0)
parallel (1.27.0)
parser (3.3.8.0)
ast (~> 2.4.1)
@@ -662,6 +664,12 @@ GEM
prettier_print
rbs
syntax_tree (>= 2.0.1)
+ table_tennis (0.0.7)
+ csv (~> 3.3)
+ ffi (~> 1.17)
+ memo_wise (~> 1.11)
+ paint (~> 2.3)
+ unicode-display_width (~> 3.1)
temple (0.10.0)
thor (1.4.0)
thruster (0.1.15-arm64-darwin)
@@ -811,6 +819,7 @@ DEPENDENCIES
syntax_tree
syntax_tree-haml
syntax_tree-rbs
+ table_tennis
thruster
turbo-rails
tzinfo-data
diff --git a/app/components/app_activity_log_component.rb b/app/components/app_activity_log_component.rb
index dec73408ea..127dc2189a 100644
--- a/app/components/app_activity_log_component.rb
+++ b/app/components/app_activity_log_component.rb
@@ -318,59 +318,87 @@ def programmes_by_id
def decision_expiration_events
all_programmes = Programme.all.to_a
- AcademicYear.all.filter_map do |academic_year|
- next if academic_year >= AcademicYear.current
+ AcademicYear.all.flat_map do |academic_year|
+ next [] if academic_year >= AcademicYear.current
- vaccinated_programmes =
- all_programmes.select do |programme|
+ not_vaccinated_programmes =
+ all_programmes.reject do |programme|
patient.vaccination_status(programme:, academic_year:).vaccinated?
end
- programmes_with_expired_consents =
- consents
- .select { it.academic_year == academic_year }
- .flat_map { programmes_for(it) }
- .reject { vaccinated_programmes.include?(it) }
-
- programmes_with_expired_triages =
- triages
- .select { it.academic_year == academic_year }
- .flat_map { programmes_for(it) }
- .reject { vaccinated_programmes.include?(it) }
-
- programmes_with_expired_psds =
- patient_specific_directions
- .select { it.academic_year == academic_year }
- .flat_map { programmes_for(it) }
- .reject { vaccinated_programmes.include?(it) }
-
- expired_items = []
- if programmes_with_expired_consents.any?
- expired_items += ["consent", "health information"]
- end
- expired_items << "triage outcome" if programmes_with_expired_triages.any?
- expired_items << "PSD status" if programmes_with_expired_psds.any?
+ vaccinated_but_seasonal_programmes =
+ all_programmes.select do |programme|
+ patient.vaccination_status(programme:, academic_year:).vaccinated? &&
+ programme.seasonal?
+ end
- next if expired_items.empty?
+ expired_items =
+ {
+ vaccinated_but_seasonal: vaccinated_but_seasonal_programmes,
+ not_vaccinated: not_vaccinated_programmes
+ }.transform_values do |programmes|
+ get_expired_items(academic_year:, programmes:)
+ end
- programmes_with_expired_items = [
- programmes_with_expired_consents,
- programmes_with_expired_triages,
- programmes_with_expired_psds
- ].flatten.uniq
+ expired_items.map do |category, expired_items_in_category|
+ expired_item_names = []
+ if expired_items_in_category[:consents].any?
+ expired_item_names += ["consent", "health information"]
+ end
+ if expired_items_in_category[:triages].any?
+ expired_item_names << "triage outcome"
+ end
+ if expired_items_in_category[:psds].any?
+ expired_item_names << "PSD status"
+ end
- expired_items_sentence =
- expired_items.to_sentence(
- words_connector: ", ",
- last_word_connector: " and "
- )
+ next [] if expired_item_names.empty?
+
+ title =
+ "#{
+ expired_item_names.to_sentence(
+ words_connector: ", ",
+ last_word_connector: " and "
+ ).upcase_first
+ } expired"
+
+ body =
+ case category
+ when :not_vaccinated
+ "#{@patient.full_name} was not vaccinated."
+ when :vaccinated_but_seasonal
+ "#{@patient.full_name} was vaccinated."
+ end
- {
- title: "#{expired_items_sentence.upcase_first} expired",
- body: "#{@patient.full_name} was not vaccinated.",
- at: academic_year.to_academic_year_date_range.end.end_of_day - 1.second,
- programmes: programmes_with_expired_items
- }
+ programmes = expired_items_in_category.values.flatten.uniq
+
+ {
+ title:,
+ body:,
+ at:
+ academic_year.to_academic_year_date_range.end.end_of_day - 1.second,
+ programmes:
+ }
+ end
+ end
+ end
+
+ private
+
+ def filter_expired(items, academic_year:, programmes:)
+ items
+ .select { it.academic_year == academic_year }
+ .flat_map { programmes_for(it) }
+ .select { programmes.include?(it) }
+ end
+
+ def get_expired_items(academic_year:, programmes:)
+ {
+ consents:,
+ triages:,
+ psds: patient_specific_directions
+ }.transform_values do |items|
+ filter_expired(items, academic_year:, programmes:)
end
end
end
diff --git a/app/components/app_imports_navigation_component.rb b/app/components/app_imports_navigation_component.rb
index d97d77e8ab..3061b3517f 100644
--- a/app/components/app_imports_navigation_component.rb
+++ b/app/components/app_imports_navigation_component.rb
@@ -46,8 +46,8 @@ def issues_text
end
def notices_text
- count = helpers.policy_scope(Patient).with_notice.count
-
+ count =
+ ImportantNotices.call(patient_scope: helpers.policy_scope(Patient)).length
safe_join(["Important notices", " ", render(AppCountComponent.new(count))])
end
end
diff --git a/app/components/app_notices_table_component.rb b/app/components/app_notices_table_component.rb
index e9b4c81d1a..d0500cc7cc 100644
--- a/app/components/app_notices_table_component.rb
+++ b/app/components/app_notices_table_component.rb
@@ -1,42 +1,13 @@
# frozen_string_literal: true
class AppNoticesTableComponent < ViewComponent::Base
- def initialize(
- deceased_patients:,
- invalidated_patients:,
- restricted_patients:,
- has_vaccination_records_dont_notify_parents_patients:
- )
+ def initialize(notices)
super
- @deceased_patients = deceased_patients
- @invalidated_patients = invalidated_patients
- @restricted_patients = restricted_patients
- @has_vaccination_records_dont_notify_parents_patients =
- has_vaccination_records_dont_notify_parents_patients
- end
-
- def render?
- @deceased_patients.present? || @invalidated_patients.present? ||
- @restricted_patients.present? ||
- @has_vaccination_records_dont_notify_parents_patients.present?
+ @notices = notices
end
private
- def notices
- all_patients =
- (
- @deceased_patients + @invalidated_patients + @restricted_patients +
- @has_vaccination_records_dont_notify_parents_patients
- ).uniq
-
- notices =
- all_patients.flat_map do |patient|
- helpers
- .patient_important_notices(patient)
- .map { |notification| notification.merge(patient:) }
- end
- notices.sort_by { it[:date_time] }.reverse
- end
+ attr_reader :notices
end
diff --git a/app/components/app_patient_card_component.rb b/app/components/app_patient_card_component.rb
index 026eed4d7e..39f9cd2673 100644
--- a/app/components/app_patient_card_component.rb
+++ b/app/components/app_patient_card_component.rb
@@ -5,8 +5,8 @@ class AppPatientCardComponent < ViewComponent::Base
<%= render AppCardComponent.new(heading_level:, section: true) do |card| %>
<% card.with_heading { "Child’s details" } %>
- <% helpers.patient_important_notices(patient).each do |notification| %>
- <%= render AppStatusComponent.new(text: notification[:message]) %>
+ <% important_notices.each do |notice| %>
+ <%= render AppStatusComponent.new(text: notice[:message]) %>
<% end %>
<%= render AppChildSummaryComponent.new(
@@ -47,4 +47,6 @@ def initialize(
:heading_level
def show_school_and_year_group = patient.show_year_group?(team: current_team)
+
+ def important_notices = ImportantNotices.call(patient:)
end
diff --git a/app/components/app_patient_programmes_table_component.rb b/app/components/app_patient_programmes_table_component.rb
new file mode 100644
index 0000000000..59270c8712
--- /dev/null
+++ b/app/components/app_patient_programmes_table_component.rb
@@ -0,0 +1,178 @@
+# frozen_string_literal: true
+
+class AppPatientProgrammesTableComponent < ViewComponent::Base
+ def initialize(patient, programmes:)
+ super
+
+ @patient = patient
+ @programmes = programmes
+ end
+
+ def call
+ govuk_table(
+ caption: CAPTION,
+ head: HEADERS,
+ rows:,
+ first_cell_is_header: true
+ )
+ end
+
+ private
+
+ attr_reader :patient, :programmes
+
+ CAPTION = "Vaccination programmes"
+ HEADERS = ["Programme name", "Status", "Notes"].freeze
+
+ def rows
+ programmes.flat_map { |programme| rows_for_programme(programme) }
+ end
+
+ def rows_for_programme(programme)
+ if programme.seasonal?
+ seasonal_programme_rows(programme)
+ else
+ non_seasonal_programme_rows(programme)
+ end
+ end
+
+ def seasonal_programme_rows(programme)
+ eligible_year_groups = eligible_year_groups_for(programme:)
+
+ AcademicYear.all.flat_map do |academic_year|
+ year_group = patient.year_group(academic_year:)
+ next unless year_group.in?(eligible_year_groups)
+
+ if vaccinated_for_academic_year?(programme:, academic_year:)
+ vaccination_records_for(programme).map do |vaccination_record|
+ build_row(programme, academic_year, vaccination_record)
+ end
+ else
+ [build_row(programme, academic_year)]
+ end
+ end
+ end
+
+ def non_seasonal_programme_rows(programme)
+ academic_year = AcademicYear.current
+ vaccination_records = vaccination_records_for(programme)
+
+ if vaccination_records.any?
+ vaccination_records.map do |vaccination_record|
+ build_row(programme, academic_year, vaccination_record)
+ end
+ else
+ [build_row(programme, academic_year)]
+ end
+ end
+
+ def build_row(programme, academic_year, vaccination_record = nil)
+ [
+ name_for_programme(programme:, academic_year:, vaccination_record:),
+ status_for_programme(programme:, academic_year:, vaccination_record:),
+ notes_for_programme(programme:, academic_year:, vaccination_record:)
+ ]
+ end
+
+ def name_for_programme(programme:, academic_year:, vaccination_record: nil)
+ name_parts = []
+
+ name_parts << "Winter #{academic_year}" if programme.seasonal?
+
+ if multi_dose?(vaccination_record)
+ name_parts << "#{vaccination_record.dose_sequence.ordinalize} dose"
+ end
+
+ if name_parts.any?
+ "#{programme.name} (#{name_parts.join(", ")})"
+ else
+ programme.name
+ end
+ end
+
+ def multi_dose?(vaccination_record)
+ vaccination_record&.dose_sequence&.> 1
+ end
+
+ def status_for_programme(programme:, academic_year:, vaccination_record: nil)
+ return vaccination_status(vaccination_record) if vaccination_record
+
+ earliest_academic_year =
+ calculate_earliest_academic_year(programme:, academic_year:)
+
+ return "—" if future_eligibility?(earliest_academic_year)
+
+ govuk_tag(text: "No outcome yet", colour: "grey")
+ end
+
+ def vaccination_status(vaccination_record)
+ if vaccination_record.administered?
+ govuk_tag(text: "Vaccinated", colour: "green")
+ else
+ govuk_tag(text: "Could not vaccinate", colour: "red")
+ end
+ end
+
+ def future_eligibility?(earliest_academic_year)
+ earliest_academic_year&.to_academic_year_date_range&.begin&.future?
+ end
+
+ def notes_for_programme(programme:, academic_year:, vaccination_record: nil)
+ return vaccination_notes(vaccination_record) if vaccination_record
+
+ eligibility_notes(programme, academic_year)
+ end
+
+ def vaccination_notes(vaccination_record)
+ if vaccination_record.administered?
+ "Vaccinated #{vaccination_record.performed_at.to_date.to_fs(:long)}"
+ else
+ vaccination_record.outcome.humanize
+ end
+ end
+
+ def eligibility_notes(programme, academic_year)
+ earliest_academic_year =
+ calculate_earliest_academic_year(programme:, academic_year:)
+
+ return "—" if earliest_academic_year.nil?
+
+ date_range = earliest_academic_year.to_academic_year_date_range
+ programme_type = programme.human_enum_name(:type)
+ eligibility_date = date_range.begin
+
+ if eligibility_date.future?
+ "Eligibility starts #{eligibility_date.to_fs(:long)}"
+ else
+ "Selected for the Year #{date_range.begin.year} to #{date_range.end.year} #{programme_type} cohort"
+ end
+ end
+
+ def vaccinated_for_academic_year?(programme:, academic_year:)
+ patient.vaccination_statuses.vaccinated.exists?(programme:, academic_year:)
+ end
+
+ def vaccination_records_for(programme)
+ @vaccination_records ||= patient.vaccination_records.order(:performed_at)
+
+ @vaccination_records.select { |record| record.programme_id == programme.id }
+ end
+
+ def eligible_year_groups_for(programme:)
+ location_ids = patient.patient_sessions.joins(:session).select(:location_id)
+
+ LocationProgrammeYearGroup
+ .where(location_id: location_ids)
+ .where(programme:)
+ .pluck_year_groups
+ end
+
+ def calculate_earliest_academic_year(programme:, academic_year:)
+ if programme.seasonal?
+ academic_year
+ elsif (earliest_year_group = eligible_year_groups_for(programme:).first)
+ patient.birth_academic_year + earliest_year_group +
+ Integer::AGE_CHILDREN_START_SCHOOL
+ end
+ end
+end
diff --git a/app/components/app_patient_search_form_component.rb b/app/components/app_patient_search_form_component.rb
index bad9edf34c..bc95479470 100644
--- a/app/components/app_patient_search_form_component.rb
+++ b/app/components/app_patient_search_form_component.rb
@@ -158,12 +158,14 @@ class AppPatientSearchFormComponent < ViewComponent::Base
link_errors: true,
label: { text: "Children missing an NHS number" } %>
- <%= f.govuk_check_box :aged_out_of_programmes,
- 1, 0,
- checked: form.aged_out_of_programmes,
- multiple: false,
- link_errors: true,
- label: { text: "Children aged out of programmes" } %>
+ <% if show_aged_out_of_programmes %>
+ <%= f.govuk_check_box :aged_out_of_programmes,
+ 1, 0,
+ checked: form.aged_out_of_programmes,
+ multiple: false,
+ link_errors: true,
+ label: { text: "Children aged out of programmes" } %>
+ <% end %>
<% end %>
<% if show_buttons_in_details? %>
@@ -195,7 +197,8 @@ def initialize(
triage_statuses: [],
vaccine_methods: [],
year_groups: [],
- heading_level: 3
+ heading_level: 3,
+ show_aged_out_of_programmes: false
)
super
@@ -211,6 +214,7 @@ def initialize(
@vaccine_methods = vaccine_methods
@year_groups = year_groups
@heading_level = heading_level
+ @show_aged_out_of_programmes = show_aged_out_of_programmes
end
private
@@ -225,7 +229,8 @@ def initialize(
:triage_statuses,
:vaccine_methods,
:year_groups,
- :heading_level
+ :heading_level,
+ :show_aged_out_of_programmes
def open_details?
@form.date_of_birth_year.present? || @form.date_of_birth_month.present? ||
diff --git a/app/components/app_patient_search_result_card_component.rb b/app/components/app_patient_search_result_card_component.rb
index eaf41ae190..f0cc6c8678 100644
--- a/app/components/app_patient_search_result_card_component.rb
+++ b/app/components/app_patient_search_result_card_component.rb
@@ -109,7 +109,7 @@ def render_status_tag(status_type, outcome)
academic_year: @academic_year
)
- status_key =
+ status =
if status_type == :triage && status_model.vaccine_method.present? &&
@programme.has_multiple_vaccine_methods?
"#{status_model.status}_#{status_model.vaccine_method}"
@@ -117,9 +117,15 @@ def render_status_tag(status_type, outcome)
status_model.status
end
+ latest_session_status =
+ if status_type == :vaccination &&
+ status_model.latest_session_status != status
+ status_model.latest_session_status
+ end
+
render AppProgrammeStatusTagsComponent.new(
- { @programme => { status: status_key } },
- outcome: outcome
+ { @programme => { status:, latest_session_status: } },
+ outcome:
)
end
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 8e54288b0c..1c1ca8b762 100644
--- a/app/components/app_patient_session_search_result_card_component.rb
+++ b/app/components/app_patient_session_search_result_card_component.rb
@@ -30,7 +30,7 @@ class AppPatientSessionSearchResultCardComponent < ViewComponent::Base
end
end
- if status_tag
+ status_tags.each do |status_tag|
summary_list.with_row do |row|
row.with_key { I18n.t(status_tag[:key], scope: %i[status label]) }
row.with_value { status_tag[:value] }
@@ -57,7 +57,7 @@ class AppPatientSessionSearchResultCardComponent < ViewComponent::Base
def initialize(patient_session, context:, programmes: [])
super
- unless context.in?(%i[consent triage register record outcome])
+ unless context.in?(%i[patients consent triage register record])
raise "Unknown context: #{context}"
end
@@ -87,6 +87,7 @@ def can_register_attendance?
patient_session:,
session_date: SessionDate.new(value: Date.current)
)
+
helpers.policy(session_attendance).new?
end
@@ -118,7 +119,7 @@ def action_required
end
def vaccination_method
- return if context == :outcome
+ return if context == :patients
programmes_to_check = programmes.select(&:has_multiple_vaccine_methods?)
@@ -141,69 +142,102 @@ def vaccination_method
Vaccine.human_enum_name(:method, vaccine_methods.first)
end
- def status_tag
- return if context == :record
-
+ def status_tags
case context
+ when :record
+ []
when :register
- {
- key: :register,
- value:
- render(
- AppRegisterStatusTagComponent.new(
- patient_session.registration_status&.status || "unknown"
- )
- )
- }
+ [register_status_tag, programme_status_tag]
when :consent
- {
- key: :consent,
- value:
- render(
- AppProgrammeStatusTagsComponent.new(
- programmes.index_with do |programme|
- patient.consent_status(programme:, academic_year:).slice(
- :status,
- :vaccine_methods
- )
- end,
- outcome: :consent
- )
- )
- }
+ [consent_status_tag]
when :triage
- {
- key: :triage,
- value:
- render(
- AppProgrammeStatusTagsComponent.new(
- programmes.index_with do |programme|
- triage_status_tag(
- patient.triage_status(programme:, academic_year:),
- programme
- )
- end,
- outcome: :triage
- )
- )
- }
+ [triage_status_tag]
else
- {
- key: :session,
- value:
- render(
- AppProgrammeStatusTagsComponent.new(
- programmes.index_with do |programme|
- patient_session.session_status(programme:).slice(:status)
- end,
- outcome: :session
- )
- )
- }
+ [programme_status_tag, session_status_tag]
end
end
- def triage_status_tag(triage_status, programme)
+ def consent_status_tag
+ {
+ key: :consent,
+ value:
+ render(
+ AppProgrammeStatusTagsComponent.new(
+ programmes.index_with do |programme|
+ patient.consent_status(programme:, academic_year:).slice(
+ :status,
+ :vaccine_methods
+ )
+ end,
+ outcome: :consent
+ )
+ )
+ }
+ end
+
+ def programme_status_tag
+ {
+ key: :programme,
+ value:
+ render(
+ AppProgrammeStatusTagsComponent.new(
+ programmes.index_with do |programme|
+ patient.vaccination_status(programme:, academic_year:).slice(
+ :status
+ )
+ end,
+ outcome: :programme
+ )
+ )
+ }
+ end
+
+ def register_status_tag
+ {
+ key: :register,
+ value:
+ render(
+ AppRegisterStatusTagComponent.new(
+ patient_session.registration_status&.status || "unknown"
+ )
+ )
+ }
+ end
+
+ def session_status_tag
+ {
+ key: :session,
+ value:
+ render(
+ AppProgrammeStatusTagsComponent.new(
+ programmes.index_with do |programme|
+ patient_session.session_status(programme:).slice(:status)
+ end,
+ outcome: :session
+ )
+ )
+ }
+ end
+
+ def triage_status_tag
+ {
+ key: :triage,
+ value:
+ render(
+ AppProgrammeStatusTagsComponent.new(
+ programmes.index_with do |programme|
+ triage_status_value(
+ patient.triage_status(programme:, academic_year:),
+ programme
+ )
+ end,
+ outcome: :triage
+ )
+ )
+ }
+ end
+
+ def triage_status_value(triage_status, programme)
status =
if triage_status.vaccine_method.present? &&
programme.has_multiple_vaccine_methods?
diff --git a/app/components/app_patient_summary_component.rb b/app/components/app_patient_summary_component.rb
index 1a8aa5bf14..12c0d0fb4d 100644
--- a/app/components/app_patient_summary_component.rb
+++ b/app/components/app_patient_summary_component.rb
@@ -24,7 +24,7 @@ def initialize(patient)
attr_reader :patient
def rows
- [date_of_birth_row, address_row]
+ [nhs_number_row, date_of_birth_row, address_row]
end
def classes
@@ -35,6 +35,25 @@ def classes
]
end
+ def nhs_number_row
+ {
+ key: {
+ text: "NHS number"
+ },
+ value: {
+ text:
+ if patient.nhs_number.present?
+ helpers.patient_nhs_number(patient)
+ else
+ helpers.link_to(
+ "Add the child's NHS number",
+ edit_nhs_number_patient_path(patient)
+ )
+ end
+ }
+ }
+ end
+
def date_of_birth_row
{
key: {
diff --git a/app/components/app_programme_status_tags_component.rb b/app/components/app_programme_status_tags_component.rb
index 644ef2b36c..b9c22a5716 100644
--- a/app/components/app_programme_status_tags_component.rb
+++ b/app/components/app_programme_status_tags_component.rb
@@ -14,7 +14,14 @@ def call
status = hash[:status]
vaccine_methods =
(hash[:vaccine_methods] if programme.has_multiple_vaccine_methods?)
- programme_status_tag(programme, status, vaccine_methods)
+ latest_session_status = hash[:latest_session_status]
+
+ programme_status_tag(
+ programme,
+ status,
+ vaccine_methods,
+ latest_session_status
+ )
end
)
end
@@ -23,7 +30,12 @@ def call
attr_reader :programme_statuses, :outcome
- def programme_status_tag(programme, status, vaccine_methods)
+ def programme_status_tag(
+ programme,
+ status,
+ vaccine_methods,
+ latest_session_status
+ )
programme_tag =
tag.strong(
programme.name,
@@ -43,6 +55,23 @@ def programme_status_tag(programme, status, vaccine_methods)
)
end
- tag.p(safe_join([programme_tag, status_tag, vaccine_methods_span]))
+ latest_session_span =
+ if latest_session_status && latest_session_status != "none_yet"
+ tag.span(
+ I18n.t(latest_session_status, scope: %i[status session label]),
+ class: "nhsuk-u-secondary-text-color"
+ )
+ end
+
+ tag.p(
+ safe_join(
+ [
+ programme_tag,
+ status_tag,
+ vaccine_methods_span,
+ latest_session_span
+ ].compact
+ )
+ )
end
end
diff --git a/app/components/app_session_actions_component.rb b/app/components/app_session_actions_component.rb
index 568485a3c8..5700c23dfd 100644
--- a/app/components/app_session_actions_component.rb
+++ b/app/components/app_session_actions_component.rb
@@ -29,6 +29,7 @@ def patient_sessions
def rows
@rows ||= [
+ no_nhs_number_row,
no_consent_response_row,
conflicting_consent_row,
triage_required_row,
@@ -37,76 +38,46 @@ def rows
].compact
end
- def no_consent_response_row
- consent_row("No consent response", status: "no_response")
- end
+ def no_nhs_number_row
+ count = patient_sessions.merge(Patient.without_nhs_number).count
+ href = session_patients_path(session, missing_nhs_number: true)
- def conflicting_consent_row
- consent_row("Conflicting consent", status: "conflicts")
+ generate_row(:children_without_nhs_number, count:, href:)
end
- def consent_row(text, status:)
+ def no_consent_response_row
+ status = "no_response"
count =
patient_sessions.has_consent_status(status, programme: programmes).count
+ href = session_consent_path(session, consent_statuses: [status])
- return nil if count.zero?
+ generate_row(:children_with_no_consent_response, count:, href:)
+ end
+ def conflicting_consent_row
+ status = "conflicts"
+ count =
+ patient_sessions.has_consent_status(status, programme: programmes).count
href = session_consent_path(session, consent_statuses: [status])
- {
- key: {
- text: text
- },
- value: {
- text: I18n.t("children", count:)
- },
- actions: [{ text: "Review", visually_hidden_text: text.downcase, href: }]
- }
+ generate_row(:children_with_conflicting_consent_response, count:, href:)
end
def triage_required_row
status = "required"
-
count =
patient_sessions.has_triage_status(status, programme: programmes).count
-
- return nil if count.zero?
-
href = session_triage_path(session, triage_status: status)
- {
- key: {
- text: "Triage needed"
- },
- value: {
- text: I18n.t("children", count:)
- },
- actions: [
- { text: "Review", visually_hidden_text: "triage needed", href: }
- ]
- }
+ generate_row(:children_requiring_triage, count:, href:)
end
def register_attendance_row
status = "unknown"
-
count = patient_sessions.has_registration_status(status).count
-
- return nil if count.zero?
-
href = session_register_path(session, register_status: status)
- {
- key: {
- text: "Register attendance"
- },
- value: {
- text: I18n.t("children", count:)
- },
- actions: [
- { text: "Review", visually_hidden_text: "register attendance", href: }
- ]
- }
+ generate_row(:children_to_register, count:, href:)
end
def ready_for_vaccinator_row
@@ -130,11 +101,31 @@ def ready_for_vaccinator_row
return nil if counts_by_programme.values.all?(&:zero?)
texts =
- counts_by_programme.map do |programme, count|
- "#{I18n.t("children", count:)} for #{programme.name_in_sentence}"
+ if counts_by_programme.values.all?(&:zero?)
+ ["No children"]
+ else
+ counts_by_programme.map do |programme, count|
+ text =
+ I18n.t(
+ :children_for_programme,
+ count:,
+ programme: programme.name_in_sentence
+ )
+ href =
+ session_record_path(
+ session,
+ search_form: {
+ programme_types: [programme.type]
+ }
+ )
+ count.positive? ? helpers.link_to(text, href) : text
+ end
end
- href = session_record_path(session)
+ actions =
+ unless counts_by_programme.values.all?(&:zero?)
+ [{ text: "Record", href: session_record_path(session) }]
+ end
{
key: {
@@ -143,9 +134,21 @@ def ready_for_vaccinator_row
value: {
text: safe_join(texts, tag.br)
},
- actions: [
- { text: "Review", visually_hidden_text: "ready for vaccinator", href: }
- ]
+ actions: actions
+ }
+ end
+
+ def generate_row(key, count:, href: nil)
+ return nil if count.zero?
+
+ {
+ key: {
+ text: I18n.t(:title, scope: key)
+ },
+ value: {
+ text:
+ (href ? helpers.link_to(I18n.t(key, count:), href).html_safe : text)
+ }
}
end
end
diff --git a/app/components/app_session_details_summary_component.rb b/app/components/app_session_details_summary_component.rb
index f791dcf724..ecdb521460 100644
--- a/app/components/app_session_details_summary_component.rb
+++ b/app/components/app_session_details_summary_component.rb
@@ -69,7 +69,7 @@ def vaccinated_row
"#{I18n.t("vaccinations_given", count:)} for #{programme.name_in_sentence}"
end
- href = session_outcome_path(session, session_status: "vaccinated")
+ href = session_patients_path(session, session_status: "vaccinated")
{
key: {
diff --git a/app/components/app_session_needs_review_warning_component.rb b/app/components/app_session_needs_review_warning_component.rb
new file mode 100644
index 0000000000..4fbd7369aa
--- /dev/null
+++ b/app/components/app_session_needs_review_warning_component.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+class AppSessionNeedsReviewWarningComponent < ViewComponent::Base
+ def call
+ render AppWarningCalloutComponent.new(heading: "Needs review") do
+ tag.ul do
+ safe_join(
+ warning_counts.filter_map do |warning, _c|
+ tag.li { make_row_from_warning(warning) }
+ end
+ )
+ end
+ end
+ end
+
+ def initialize(session:)
+ super
+ @session = session
+ end
+
+ def render?
+ warning_counts.values.any?(&:positive?)
+ end
+
+ private
+
+ def warning_href
+ {
+ children_without_nhs_number:
+ session_patients_path(@session, missing_nhs_number: true)
+ }
+ end
+
+ def warning_counts
+ @warning_counts ||= {
+ children_without_nhs_number:
+ patient_sessions.merge(Patient.without_nhs_number).count
+ }
+ end
+
+ def make_row_from_warning(warning)
+ return if warning_counts[warning].zero?
+ link_to(t(warning, count: warning_counts[warning]), warning_href[warning])
+ end
+
+ def patient_sessions
+ @session
+ .patient_sessions
+ .joins(:patient, :session)
+ .appear_in_programmes(@session.programmes)
+ end
+end
diff --git a/app/components/app_vaccination_record_api_sync_status_component.rb b/app/components/app_vaccination_record_api_sync_status_component.rb
index 704c71dcfd..5e5d196988 100644
--- a/app/components/app_vaccination_record_api_sync_status_component.rb
+++ b/app/components/app_vaccination_record_api_sync_status_component.rb
@@ -56,11 +56,11 @@ def additional_information_text
!vaccination_record.programme.type.in?(
NHS::ImmunisationsAPI::PROGRAMME_TYPES
)
- if !notify_parents
+ if is_not_a_synced_programme
+ "Records are currently not synced for this programme"
+ elsif notify_parents == false
"The child gave consent under Gillick competence and does not want their parents to be notified. " \
"You must let the child’s GP know they were vaccinated."
- elsif is_not_a_synced_programme
- "Records are currently not synced for this programme"
elsif recorded_in_service?
"Records are not synced if the vaccination was not given"
elsif vaccination_record.session.nil?
diff --git a/app/controllers/api/testing/teams_controller.rb b/app/controllers/api/testing/teams_controller.rb
index bf67223ebb..770aa50b2a 100644
--- a/app/controllers/api/testing/teams_controller.rb
+++ b/app/controllers/api/testing/teams_controller.rb
@@ -9,13 +9,7 @@ def destroy
keep_itself = ActiveModel::Type::Boolean.new.cast(params[:keep_itself])
- # TODO: Select the right team based on an identifier.
- team =
- Team.joins(:organisation).find_by!(
- organisation: {
- ods_code: params[:ods_code]
- }
- )
+ team = Team.find_by!(workgroup: params[:workgroup])
@start_time = Time.zone.now
@@ -40,13 +34,16 @@ def destroy
log_destroy(SessionDate.where(session: sessions))
- log_destroy(SchoolMove.where(patient_id: patient_ids))
- log_destroy(SchoolMove.where(team:))
- log_destroy(SchoolMoveLogEntry.where(patient_id: patient_ids))
log_destroy(AccessLogEntry.where(patient_id: patient_ids))
- log_destroy(NotifyLogEntry.where(patient_id: patient_ids))
+ log_destroy(ArchiveReason.where(patient_id: patient_ids))
+ log_destroy(ConsentNotification.where(patient_id: patient_ids))
+ log_destroy(Note.where(patient_id: patient_ids))
# In local dev we can end up with NotifyLogEntries without a patient
log_destroy(NotifyLogEntry.where(patient_id: nil))
+ log_destroy(NotifyLogEntry.where(patient_id: patient_ids))
+ log_destroy(SchoolMove.where(patient_id: patient_ids))
+ log_destroy(SchoolMove.where(team:))
+ log_destroy(SchoolMoveLogEntry.where(patient_id: patient_ids))
log_destroy(VaccinationRecord.where(patient_id: patient_ids))
log_destroy(ConsentForm.where(team:))
diff --git a/app/controllers/consent_forms_controller.rb b/app/controllers/consent_forms_controller.rb
index 9d779d9180..2336e4ec74 100644
--- a/app/controllers/consent_forms_controller.rb
+++ b/app/controllers/consent_forms_controller.rb
@@ -138,7 +138,8 @@ def set_patient
@patient =
policy_scope(Patient).includes(
parent_relationships: :parent,
- pending_sessions: :programmes
+ pending_sessions: :programmes,
+ vaccination_records: :programme
).find(params[:patient_id])
end
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 2fe2ba2e1c..eed660e08c 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -6,7 +6,9 @@ class DashboardController < ApplicationController
layout "full"
def index
- @important_notices =
- (policy_scope(Patient).with_notice.count if policy(:notices).index?)
+ @notices_count =
+ if policy(:notices).index?
+ ImportantNotices.call(patient_scope: policy_scope(Patient)).length
+ end
end
end
diff --git a/app/controllers/imports/notices_controller.rb b/app/controllers/imports/notices_controller.rb
index be75f843bb..a716f92678 100644
--- a/app/controllers/imports/notices_controller.rb
+++ b/app/controllers/imports/notices_controller.rb
@@ -6,19 +6,6 @@ class Imports::NoticesController < ApplicationController
def index
authorize :notices
- @deceased_patients =
- policy_scope(Patient).deceased.includes(vaccination_records: :programme)
- @invalidated_patients =
- policy_scope(Patient).invalidated.includes(
- vaccination_records: :programme
- )
- @restricted_patients =
- policy_scope(Patient).restricted.includes(vaccination_records: :programme)
- @has_vaccination_records_dont_notify_parents_patients =
- policy_scope(
- Patient
- ).has_vaccination_records_dont_notify_parents.includes(
- vaccination_records: :programme
- )
+ @notices = ImportantNotices.call(patient_scope: policy_scope(Patient))
end
end
diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb
index 7f338a362f..220e33a25a 100644
--- a/app/controllers/imports_controller.rb
+++ b/app/controllers/imports_controller.rb
@@ -8,7 +8,7 @@ def index
end
def create
- DraftImport.new(request_session: session, current_user:).reset!
+ DraftImport.new(request_session: session, current_user:).clear!
redirect_to draft_import_path(Wicked::FIRST_STEP)
end
end
diff --git a/app/controllers/patient_sessions/base_controller.rb b/app/controllers/patient_sessions/base_controller.rb
index bdcc9138d9..8a3614bc56 100644
--- a/app/controllers/patient_sessions/base_controller.rb
+++ b/app/controllers/patient_sessions/base_controller.rb
@@ -55,7 +55,7 @@ def set_breadcrumb_item
return_to = params[:return_to]
return nil if return_to.blank?
- known_return_to = %w[consent triage register record outcome]
+ known_return_to = %w[patients consent triage register record]
return unless return_to.in?(known_return_to)
@breadcrumb_item = {
diff --git a/app/controllers/patient_sessions/consents_controller.rb b/app/controllers/patient_sessions/consents_controller.rb
index 29ff2579fe..559da394a8 100644
--- a/app/controllers/patient_sessions/consents_controller.rb
+++ b/app/controllers/patient_sessions/consents_controller.rb
@@ -9,9 +9,9 @@ class PatientSessions::ConsentsController < PatientSessions::BaseController
def create
authorize Consent
- @draft_consent =
- DraftConsent.new(request_session: session, current_user:).tap(&:reset!)
+ @draft_consent = DraftConsent.new(request_session: session, current_user:)
+ @draft_consent.clear_attributes
@draft_consent.assign_attributes(create_params)
if @draft_consent.save
@@ -138,8 +138,7 @@ def create_params
{
patient_session: @patient_session,
programme: @programme,
- recorded_by: current_user,
- vaccine_methods: %w[injection]
+ recorded_by: current_user
}
end
diff --git a/app/controllers/patient_sessions/programmes_controller.rb b/app/controllers/patient_sessions/programmes_controller.rb
index 482ac048aa..df0ea9d45c 100644
--- a/app/controllers/patient_sessions/programmes_controller.rb
+++ b/app/controllers/patient_sessions/programmes_controller.rb
@@ -17,7 +17,7 @@ def record_already_vaccinated
draft_vaccination_record =
DraftVaccinationRecord.new(request_session: session, current_user:)
- draft_vaccination_record.reset!
+ draft_vaccination_record.clear_attributes
draft_vaccination_record.update!(
first_active_wizard_step: :confirm,
location_name: @session.clinic? ? "Unknown" : nil,
diff --git a/app/controllers/patients_controller.rb b/app/controllers/patients_controller.rb
index 07bf517087..5b23b7da30 100644
--- a/app/controllers/patients_controller.rb
+++ b/app/controllers/patients_controller.rb
@@ -7,12 +7,12 @@ class PatientsController < ApplicationController
before_action :set_patient, except: :index
before_action :record_access_log_entry, only: %i[show log]
+ layout "full"
+
def index
patients = @form.apply(policy_scope(Patient).includes(:school))
@pagy, @patients = pagy(patients)
-
- render layout: "full"
end
def show
@@ -27,7 +27,18 @@ def log
end
def edit
- render layout: "full"
+ end
+
+ def invite_to_clinic
+ session =
+ current_team.generic_clinic_session(academic_year: AcademicYear.pending)
+
+ PatientSession.find_or_create_by!(patient: @patient, session:)
+
+ redirect_to patient_path(@patient),
+ flash: {
+ success: "#{@patient.full_name} invited to the clinic"
+ }
end
private
@@ -39,7 +50,8 @@ def set_patient
:school,
consents: %i[parent patient],
parent_relationships: :parent,
- patient_sessions: %i[location session_attendances]
+ patient_sessions: %i[location session_attendances],
+ vaccination_records: :programme
).find(params[:id])
end
diff --git a/app/controllers/programmes/patients_controller.rb b/app/controllers/programmes/patients_controller.rb
index 829df9ea9b..cbe6bd1d68 100644
--- a/app/controllers/programmes/patients_controller.rb
+++ b/app/controllers/programmes/patients_controller.rb
@@ -26,7 +26,7 @@ def index
def import
draft_import = DraftImport.new(request_session: session, current_user:)
- draft_import.reset!
+ draft_import.clear_attributes
draft_import.update!(type: "cohort")
steps = draft_import.wizard_steps
diff --git a/app/controllers/programmes/reports_controller.rb b/app/controllers/programmes/reports_controller.rb
index 4129088a34..69dbe14c80 100644
--- a/app/controllers/programmes/reports_controller.rb
+++ b/app/controllers/programmes/reports_controller.rb
@@ -5,7 +5,7 @@ def create
vaccination_report =
VaccinationReport.new(request_session: session, current_user:)
- vaccination_report.reset!
+ vaccination_report.clear_attributes
vaccination_report.update!(
programme: @programme,
academic_year: @academic_year
diff --git a/app/controllers/school_moves/exports_controller.rb b/app/controllers/school_moves/exports_controller.rb
index a06f901620..65d96a15b9 100644
--- a/app/controllers/school_moves/exports_controller.rb
+++ b/app/controllers/school_moves/exports_controller.rb
@@ -8,8 +8,7 @@ class SchoolMoves::ExportsController < ApplicationController
skip_after_action :verify_policy_scoped
def create
- @school_move_export.reset!
-
+ @school_move_export.clear!
redirect_to school_move_export_path(Wicked::FIRST_STEP)
end
diff --git a/app/controllers/sessions/consent_controller.rb b/app/controllers/sessions/consent_controller.rb
index 1249be1e72..a7a41a76c2 100644
--- a/app/controllers/sessions/consent_controller.rb
+++ b/app/controllers/sessions/consent_controller.rb
@@ -9,13 +9,14 @@ class Sessions::ConsentController < ApplicationController
layout "full"
def show
- @statuses = Patient::ConsentStatus.statuses.keys
+ @statuses = Patient::ConsentStatus.statuses.keys - %w[not_required]
scope =
- @session.patient_sessions.includes_programmes.includes(
- :latest_note,
- patient: :consent_statuses
- )
+ @session
+ .patient_sessions
+ .includes_programmes
+ .includes(:latest_note, patient: :consent_statuses)
+ .has_consent_status(@statuses, programme: @form.programmes)
patient_sessions = @form.apply(scope)
@pagy, @patient_sessions = pagy(patient_sessions)
diff --git a/app/controllers/sessions/invite_to_clinic_controller.rb b/app/controllers/sessions/invite_to_clinic_controller.rb
index 7910784d49..070868ae10 100644
--- a/app/controllers/sessions/invite_to_clinic_controller.rb
+++ b/app/controllers/sessions/invite_to_clinic_controller.rb
@@ -3,6 +3,7 @@
class Sessions::InviteToClinicController < ApplicationController
before_action :set_session
before_action :set_generic_clinic_session
+ before_action :set_patient_sessions_to_invite
before_action :set_invitations_to_send
skip_after_action :verify_policy_scoped
@@ -13,14 +14,11 @@ def edit
def update
if @session.school?
- SendClinicInitialInvitationsJob.perform_later(
- @generic_clinic_session,
- school: @session.location,
- programmes: @session.programmes.to_a
- )
+ factory.create_patient_sessions!
+
flash[
:success
- ] = "Clinic invitations sent for #{I18n.t("children", count: @invitations_to_send)}"
+ ] = "#{I18n.t("children", count: @invitations_to_send)} invited to the clinic"
else
SendClinicSubsequentInvitationsJob.perform_later(@session)
flash[
@@ -44,36 +42,39 @@ def set_session
def set_generic_clinic_session
@generic_clinic_session =
- (
- if @session.clinic?
- @session
- else
- @session.team.generic_clinic_session(
- academic_year: @session.academic_year
- )
- end
- )
+ if @session.clinic?
+ @session
+ else
+ @session.team.generic_clinic_session(
+ academic_year: @session.academic_year
+ )
+ end
+ end
+
+ def set_patient_sessions_to_invite
+ @patient_sessions_to_invite =
+ if @session.school?
+ factory.patient_sessions_to_create
+ else
+ session_date = @generic_clinic_session.next_date(include_today: true)
+ SendClinicSubsequentInvitationsJob.new.patient_sessions(
+ @session,
+ session_date:
+ )
+ end
end
def set_invitations_to_send
- session_date = @generic_clinic_session.next_date(include_today: true)
+ @invitations_to_send = @patient_sessions_to_invite.length
+ end
- @invitations_to_send =
+ def factory
+ @factory ||=
if @session.school?
- SendClinicInitialInvitationsJob
- .new
- .patient_sessions(
- @generic_clinic_session,
- school: @session.location,
- programmes: @session.programmes.to_a,
- session_date:
- )
- .length
- else
- SendClinicSubsequentInvitationsJob
- .new
- .patient_sessions(@session, session_date:)
- .length
+ ClinicPatientSessionsFactory.new(
+ school_session: @session,
+ generic_clinic_session: @generic_clinic_session
+ )
end
end
end
diff --git a/app/controllers/sessions/outcome_controller.rb b/app/controllers/sessions/patients_controller.rb
similarity index 81%
rename from app/controllers/sessions/outcome_controller.rb
rename to app/controllers/sessions/patients_controller.rb
index 0730dcf2f2..9541a86491 100644
--- a/app/controllers/sessions/outcome_controller.rb
+++ b/app/controllers/sessions/patients_controller.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Sessions::OutcomeController < ApplicationController
+class Sessions::PatientsController < ApplicationController
include PatientSearchFormConcern
before_action :set_session
@@ -14,7 +14,8 @@ def show
scope =
@session.patient_sessions.includes_programmes.includes(
:latest_note,
- :session_statuses
+ :session_statuses,
+ patient: :vaccination_statuses
)
patient_sessions = @form.apply(scope)
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 8817f45d66..99d59f9be4 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -57,7 +57,7 @@ def edit
def import
draft_import = DraftImport.new(request_session: session, current_user:)
- draft_import.reset!
+ draft_import.clear_attributes
draft_import.update!(location: @session.location, type: "class")
steps = draft_import.wizard_steps
diff --git a/app/controllers/vaccination_records_controller.rb b/app/controllers/vaccination_records_controller.rb
index 13b0790a1f..7d70ca2cc5 100644
--- a/app/controllers/vaccination_records_controller.rb
+++ b/app/controllers/vaccination_records_controller.rb
@@ -50,7 +50,11 @@ def set_vaccination_record
:location,
:performed_by_user,
:programme,
- patient: [:gp_practice, :school, { parent_relationships: :parent }],
+ patient: [
+ :gp_practice,
+ :school,
+ { parent_relationships: :parent, vaccination_records: :programme }
+ ],
session: %i[session_dates],
vaccine: :programme
).find(params[:id])
@@ -75,8 +79,8 @@ def set_breadcrumb_items
href: session_path(@session)
}
@breadcrumb_items << {
- text: t("sessions.tabs.outcome"),
- href: session_outcome_path(@session)
+ text: t("sessions.tabs.patients"),
+ href: session_patients_path(@session)
}
@breadcrumb_items << {
text: @patient.full_name,
diff --git a/app/forms/concerns/patient_merge_form_concern.rb b/app/forms/concerns/patient_merge_form_concern.rb
index 0bb30ea4b9..2e79e2f327 100644
--- a/app/forms/concerns/patient_merge_form_concern.rb
+++ b/app/forms/concerns/patient_merge_form_concern.rb
@@ -11,8 +11,14 @@ module PatientMergeFormConcern
def existing_patient
@existing_patient ||=
if nhs_number.present?
- patient_policy_scope.find_by(nhs_number:) ||
- Patient.where.missing(:patient_sessions).find_by(nhs_number:)
+ patient_policy_scope.includes(vaccination_records: :programme).find_by(
+ nhs_number:
+ ) ||
+ Patient
+ .where
+ .missing(:patient_sessions)
+ .includes(vaccination_records: :programme)
+ .find_by(nhs_number:)
end
end
diff --git a/app/forms/search_form.rb b/app/forms/search_form.rb
index 4e40097aff..dfcb1350b3 100644
--- a/app/forms/search_form.rb
+++ b/app/forms/search_form.rb
@@ -24,7 +24,4 @@ def request_session_key = "search_form_#{path_key}"
def path_key
@path_key ||= Digest::MD5.hexdigest(@request_path).first(8)
end
-
- def reset_unused_fields
- end
end
diff --git a/app/forms/vaccinate_form.rb b/app/forms/vaccinate_form.rb
index 4be8bb31a4..60d231bfeb 100644
--- a/app/forms/vaccinate_form.rb
+++ b/app/forms/vaccinate_form.rb
@@ -64,7 +64,7 @@ def save(draft_vaccination_record:)
return false unless pre_screening.save
- draft_vaccination_record.reset!
+ draft_vaccination_record.clear_attributes
if administered?
draft_vaccination_record.outcome = "administered"
diff --git a/app/helpers/patients_helper.rb b/app/helpers/patients_helper.rb
index 9957fb5c27..32420ffae4 100644
--- a/app/helpers/patients_helper.rb
+++ b/app/helpers/patients_helper.rb
@@ -52,51 +52,4 @@ def patient_year_group(patient, academic_year:)
def patient_parents(patient)
format_parents_with_relationships(patient.parent_relationships)
end
-
- def patient_important_notices(patient)
- notifications = []
-
- if patient.deceased?
- notifications << {
- date_time: patient.date_of_death_recorded_at,
- message: "Record updated with child’s date of death"
- }
- end
-
- if patient.invalidated?
- notifications << {
- date_time: patient.invalidated_at,
- message: "Record flagged as invalid"
- }
- end
-
- if patient.restricted?
- notifications << {
- date_time: patient.restricted_at,
- message: "Record flagged as sensitive"
- }
- end
-
- no_notify_vaccination_records =
- patient.vaccination_records.select { it.notify_parents == false }
- if no_notify_vaccination_records.any?
- notifications << {
- date_time: no_notify_vaccination_records.maximum(:performed_at),
- message:
- "Child gave consent for #{format_vaccinations(no_notify_vaccination_records)} under Gillick competence and " \
- "does not want their parents to be notified. " \
- "These records will not be automatically synced with GP records. " \
- "Your team must let the child's GP know they were vaccinated."
- }
- end
-
- notifications.sort_by { |notification| notification[:date_time] }.reverse
- end
-
- private
-
- def format_vaccinations(vaccination_records)
- "#{vaccination_records.map(&:programme).map(&:name).to_sentence} " \
- "#{"vaccination".pluralize(vaccination_records.length)}"
- end
end
diff --git a/app/jobs/enqueue_clinic_session_invitations_job.rb b/app/jobs/enqueue_clinic_session_invitations_job.rb
index 1bcaf303e7..a07171451d 100644
--- a/app/jobs/enqueue_clinic_session_invitations_job.rb
+++ b/app/jobs/enqueue_clinic_session_invitations_job.rb
@@ -4,20 +4,10 @@ class EnqueueClinicSessionInvitationsJob < ApplicationJob
queue_as :notifications
def perform
- Session
- .send_invitations
- .includes(:programmes)
- .joins(:location)
- .merge(Location.clinic)
- .find_each do |session|
- # We're only inviting patients who don't have a school.
- # Patients who have a school are sent invitations manually by the
- # nurse when they're finished at a school.
- SendClinicInitialInvitationsJob.perform_now(
- session,
- school: nil,
- programmes: session.programmes
- )
- end
+ sessions = Session.send_invitations.joins(:location).merge(Location.clinic)
+
+ sessions.find_each do |session|
+ SendClinicInitialInvitationsJob.perform_later(session)
+ end
end
end
diff --git a/app/jobs/send_clinic_initial_invitations_job.rb b/app/jobs/send_clinic_initial_invitations_job.rb
index 5c414061da..9425946ba9 100644
--- a/app/jobs/send_clinic_initial_invitations_job.rb
+++ b/app/jobs/send_clinic_initial_invitations_job.rb
@@ -5,23 +5,20 @@ class SendClinicInitialInvitationsJob < ApplicationJob
queue_as :notifications
- def perform(session, school:, programmes:)
+ def perform(session)
raise InvalidLocation unless session.clinic?
session_date = session.next_date(include_today: true)
raise NoSessionDates if session_date.nil?
- patient_sessions(
- session,
- school:,
- programmes:,
- session_date:
- ).each do |patient_session|
+ patient_sessions(session, session_date:).each do |patient_session|
send_notification(patient_session:, session_date:)
end
end
- def patient_sessions(session, school:, programmes:, session_date:)
+ def patient_sessions(session, session_date:)
+ programmes = session.programmes
+
# We only send initial invitations to patients who haven't already
# received an invitation.
@@ -33,14 +30,9 @@ def patient_sessions(session, school:, programmes:, session_date:)
:session_notifications,
patient: %i[consents parents vaccination_records]
)
- .where(patient: { school: })
.reject { it.session_notifications.any? }
- .select do
- should_send_notification?(
- patient_session: it,
- programmes:,
- session_date:
- )
+ .select do |patient_session|
+ should_send_notification?(patient_session:, programmes:, session_date:)
end
end
end
diff --git a/app/jobs/send_clinic_subsequent_invitations_job.rb b/app/jobs/send_clinic_subsequent_invitations_job.rb
index b2498283a1..f5637bf8c2 100644
--- a/app/jobs/send_clinic_subsequent_invitations_job.rb
+++ b/app/jobs/send_clinic_subsequent_invitations_job.rb
@@ -24,8 +24,9 @@ def patient_sessions(session, session_date:)
session
.patient_sessions
- .eager_load(:patient)
- .preload(
+ .joins(:patient)
+ .includes_programmes
+ .includes(
:session_notifications,
patient: %i[consents parents vaccination_records]
)
diff --git a/app/lib/clinic_patient_sessions_factory.rb b/app/lib/clinic_patient_sessions_factory.rb
new file mode 100644
index 0000000000..6128b56bef
--- /dev/null
+++ b/app/lib/clinic_patient_sessions_factory.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+class ClinicPatientSessionsFactory
+ def initialize(school_session:, generic_clinic_session:)
+ @school_session = school_session
+ @generic_clinic_session = generic_clinic_session
+ end
+
+ def create_patient_sessions!
+ PatientSession.import!(
+ patient_sessions_to_create,
+ on_duplicate_key_ignore: true
+ )
+ end
+
+ def patient_sessions_to_create
+ patient_sessions_in_clinic =
+ patient_sessions_in_school.map do |patient_session|
+ PatientSession.includes(:session_notifications).find_or_initialize_by(
+ patient: patient_session.patient,
+ session: generic_clinic_session
+ )
+ end
+
+ patient_sessions_in_clinic.select do |patient_session|
+ SendClinicInitialInvitationsJob.new.should_send_notification?(
+ patient_session:,
+ programmes:,
+ session_date:
+ )
+ end
+ end
+
+ private
+
+ attr_reader :school_session, :generic_clinic_session
+
+ def programmes
+ @programmes ||= school_session.programmes.to_a
+ end
+
+ def session_date
+ @session_date ||= generic_clinic_session.next_date(include_today: true)
+ end
+
+ def patient_sessions_in_school
+ school_session.patient_sessions.includes(:patient)
+ end
+end
diff --git a/app/lib/important_notices.rb b/app/lib/important_notices.rb
new file mode 100644
index 0000000000..c885c40704
--- /dev/null
+++ b/app/lib/important_notices.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+class ImportantNotices
+ def initialize(patient_scope: nil, patient: nil)
+ @patient_scope = patient_scope
+ @patient = patient
+
+ if patient_scope.nil? && patient.nil?
+ raise "Pass either a patient_scope or a patient."
+ end
+ end
+
+ def call
+ notices =
+ patients.flat_map do |patient|
+ notices_for_patient(patient).map { it.merge(patient:) }
+ end
+
+ notices.sort_by { it[:date_time] }.reverse
+ end
+
+ def self.call(...) = new(...).call
+
+ private_class_method :new
+
+ private
+
+ attr_reader :patient_scope, :patient
+
+ def patients
+ if patient
+ [patient]
+ else
+ patient_scope_with_notices.includes(vaccination_records: :programme)
+ end
+ end
+
+ def patient_scope_with_notices
+ patient_scope
+ .deceased
+ .or(patient_scope.invalidated)
+ .or(patient_scope.restricted)
+ .or(patient_scope.has_vaccination_records_dont_notify_parents)
+ end
+
+ def notices_for_patient(patient)
+ notices = []
+
+ if patient.deceased?
+ notices << {
+ date_time: patient.date_of_death_recorded_at,
+ message: "Record updated with child’s date of death"
+ }
+ end
+
+ if patient.invalidated?
+ notices << {
+ date_time: patient.invalidated_at,
+ message: "Record flagged as invalid"
+ }
+ end
+
+ if patient.restricted?
+ notices << {
+ date_time: patient.restricted_at,
+ message: "Record flagged as sensitive"
+ }
+ end
+
+ no_notify_vaccination_records =
+ patient.vaccination_records.select { it.notify_parents == false }
+ if no_notify_vaccination_records.any?
+ vaccinations_sentence =
+ "#{no_notify_vaccination_records.map(&:programme).map(&:name).to_sentence} " \
+ "#{"vaccination".pluralize(no_notify_vaccination_records.length)}"
+
+ notices << {
+ date_time: no_notify_vaccination_records.maximum(:performed_at),
+ message:
+ "Child gave consent for #{vaccinations_sentence} under Gillick competence and " \
+ "does not want their parents to be notified. " \
+ "These records will not be automatically synced with GP records. " \
+ "Your team must let the child's GP know they were vaccinated."
+ }
+ end
+
+ notices
+ end
+end
diff --git a/app/lib/location_sessions_factory.rb b/app/lib/location_sessions_factory.rb
index 32c137bcf9..01d7a6ab51 100644
--- a/app/lib/location_sessions_factory.rb
+++ b/app/lib/location_sessions_factory.rb
@@ -53,7 +53,7 @@ def grouped_programmes
def patient_ids
@patient_ids ||=
if location.generic_clinic?
- team.patients.pluck(:id)
+ team.patients.where(school: nil).pluck(:id)
else
team.patients.where(school: location).pluck(:id)
end
diff --git a/app/lib/mavis_cli.rb b/app/lib/mavis_cli.rb
index b4ddd39e4e..aa0bd86ede 100644
--- a/app/lib/mavis_cli.rb
+++ b/app/lib/mavis_cli.rb
@@ -10,7 +10,7 @@ def self.load_rails
def self.progress_bar(total)
@progress_bar ||=
ProgressBar.create(
- total: total,
+ total:,
format: "%a %b\u{15E7}%i %p%% %t",
progress_mark: " ",
remainder_mark: "\u{FF65}"
@@ -31,5 +31,7 @@ def self.progress_bar(total)
require_relative "mavis_cli/schools/remove_programme_year_group"
require_relative "mavis_cli/teams/add_programme"
require_relative "mavis_cli/teams/create_sessions"
+require_relative "mavis_cli/teams/list"
+require_relative "mavis_cli/teams/onboard"
require_relative "mavis_cli/vaccination_records/generate_fhir"
require_relative "mavis_cli/vaccination_records/sync"
diff --git a/app/lib/mavis_cli/clinics/add_to_team.rb b/app/lib/mavis_cli/clinics/add_to_team.rb
index 08adf5d3c4..61979ae913 100644
--- a/app/lib/mavis_cli/clinics/add_to_team.rb
+++ b/app/lib/mavis_cli/clinics/add_to_team.rb
@@ -5,23 +5,17 @@ module Clinics
class AddToTeam < Dry::CLI::Command
desc "Add an existing clinic to a team"
- argument :team_ods_code, required: true, desc: "The ODS code of the team"
+ argument :workgroup, required: true, desc: "The ODS code of the team"
argument :subteam, required: true, desc: "The subteam of the team"
- argument :clinic_ods_codes,
+ argument :ods_codes,
type: :array,
required: true,
desc: "The ODS codes of the clinics"
- def call(team_ods_code:, subteam:, clinic_ods_codes:, **)
+ def call(workgroup:, subteam:, ods_codes:, **)
MavisCLI.load_rails
- # TODO: Select the right team based on an identifier.
- team =
- Team.joins(:organisation).find_by(
- organisation: {
- ods_code: team_ods_code
- }
- )
+ team = Team.find_by(workgroup:)
if team.nil?
warn "Could not find team."
@@ -36,7 +30,7 @@ def call(team_ods_code:, subteam:, clinic_ods_codes:, **)
end
ActiveRecord::Base.transaction do
- clinic_ods_codes.each do |ods_code|
+ ods_codes.each do |ods_code|
location = Location.clinic.find_by(ods_code:)
if location.nil?
diff --git a/app/lib/mavis_cli/generate/cohort_imports.rb b/app/lib/mavis_cli/generate/cohort_imports.rb
index 6f27b312d4..cbcf1f2244 100644
--- a/app/lib/mavis_cli/generate/cohort_imports.rb
+++ b/app/lib/mavis_cli/generate/cohort_imports.rb
@@ -6,28 +6,41 @@ module MavisCLI
module Generate
class CohortImports < Dry::CLI::Command
desc "Generate cohort imports"
- option :patients,
+
+ option :team_workgroup,
+ aliases: ["-w"],
+ default: "A9A5A",
+ desc: "Workgroup of team to generate consents for"
+
+ option :programme_types,
+ aliases: ["-p"],
+ default: [],
+ desc:
+ "Programme type to generate consents for (hpv, menacwy, td_ipv, etc)"
+
+ option :patient_count,
+ aliases: ["-c"],
type: :integer,
required: true,
default: 10,
desc: "Number of patients to create"
- option :ods_code,
- type: :string,
- default: "A9A5A",
- desc: "ODS code of the organisation to use for the cohort import"
- def call(patients:, ods_code:)
+ def call(team_workgroup:, programme_types:, patient_count:)
MavisCLI.load_rails
- patient_count = patients.to_i
+ team = Team.find_by!(workgroup: team_workgroup)
+ programmes = Programme.where(type: programme_types)
+ patient_count = patient_count.to_i
+
progress_bar = MavisCLI.progress_bar(patient_count)
- puts "Generating cohort import for ods code #{ods_code} with" \
+ puts "Generating cohort import for team #{team_workgroup} with" \
" #{patient_count} patients..."
result =
::Generate::CohortImports.call(
- ods_code:,
+ team:,
+ programmes:,
patient_count:,
progress_bar:
)
diff --git a/app/lib/mavis_cli/generate/consents.rb b/app/lib/mavis_cli/generate/consents.rb
index 09f4350b98..353d97eadb 100644
--- a/app/lib/mavis_cli/generate/consents.rb
+++ b/app/lib/mavis_cli/generate/consents.rb
@@ -6,10 +6,10 @@ module MavisCLI
module Generate
class Consents < Dry::CLI::Command
desc "Generate consents"
- option :team,
- aliases: ["-o"],
+ option :team_workgroup,
+ aliases: ["-w"],
default: "A9A5A",
- desc: "ODS code of team to generate consents for"
+ desc: "Workgroup of team to generate consents for"
option :programme_type,
aliases: ["-p"],
default: "hpv",
@@ -33,7 +33,7 @@ class Consents < Dry::CLI::Command
desc: "Number of refused consents to create"
def call(
- team:,
+ team_workgroup:,
programme_type:,
given:,
needing_triage:,
@@ -46,9 +46,7 @@ def call(
session = Session.find(session_id) if session_id
::Generate::Consents.call(
- # TODO: Select the right team based on an identifier.
- team:
- Team.joins(:organisation).find_by(organisation: { ods_code: team }),
+ team: Team.find_by(workgroup: team_workgroup),
programme: Programme.find_by(type: programme_type),
session:,
given: given.to_i,
diff --git a/app/lib/mavis_cli/generate/vaccination_records.rb b/app/lib/mavis_cli/generate/vaccination_records.rb
index 70bae1b38e..969305129e 100644
--- a/app/lib/mavis_cli/generate/vaccination_records.rb
+++ b/app/lib/mavis_cli/generate/vaccination_records.rb
@@ -4,10 +4,10 @@ module MavisCLI
module Generate
class VaccinationRecords < Dry::CLI::Command
desc "Generate vaccination records (and attendances if required)"
- option :team,
- aliases: ["-o"],
+ option :team_workgroup,
+ aliases: ["-w"],
default: "A9A5A",
- desc: "ODS code of team to generate consents for"
+ desc: "Workgroup of team to generate consents for"
option :programme_type,
aliases: ["-p"],
default: "hpv",
@@ -23,15 +23,19 @@ class VaccinationRecords < Dry::CLI::Command
aliases: ["-A"],
desc: "Number of administered vaccination records to create"
- def call(team:, programme_type:, administered:, session_id: nil, **)
+ def call(
+ team_workgroup:,
+ programme_type:,
+ administered:,
+ session_id: nil,
+ **
+ )
MavisCLI.load_rails
session = Session.find(session_id) if session_id
::Generate::VaccinationRecords.call(
- # TODO: Select the right team based on an identifier.
- team:
- Team.joins(:organisation).find_by(organisation: { ods_code: team }),
+ team: Team.find_by(workgroup: team_workgroup),
programme: Programme.includes(:teams).find_by(type: programme_type),
session:,
administered: administered.to_i
diff --git a/app/lib/mavis_cli/gias/import.rb b/app/lib/mavis_cli/gias/import.rb
index f4c54a01c2..f14271c4f5 100644
--- a/app/lib/mavis_cli/gias/import.rb
+++ b/app/lib/mavis_cli/gias/import.rb
@@ -17,18 +17,17 @@ def call(input_file:, **)
csv_entry = zip.glob("edubasealldata*.csv").first
csv_content = csv_entry.get_input_stream.read
- total_rows = CSV.parse(csv_content).count - 1 # Subtract 1 for header
+ rows =
+ CSV.parse(csv_content, headers: true, encoding: "ISO-8859-1:UTF-8")
+
+ row_count = rows.length
batch_size = 1000
schools = []
- puts "Starting import of #{total_rows} schools."
- progress_bar = MavisCLI.progress_bar(total_rows)
+ puts "Starting import of #{row_count} schools."
+ progress_bar = MavisCLI.progress_bar(row_count + 1)
- CSV.parse(
- csv_content,
- headers: true,
- encoding: "ISO-8859-1:UTF-8"
- ) do |row|
+ rows.each do |row|
gias_establishment_number = row["EstablishmentNumber"]
next if gias_establishment_number.blank? # closed school that never opened
diff --git a/app/lib/mavis_cli/schools/add_to_team.rb b/app/lib/mavis_cli/schools/add_to_team.rb
index eb8ae6c7aa..a5d6c36239 100644
--- a/app/lib/mavis_cli/schools/add_to_team.rb
+++ b/app/lib/mavis_cli/schools/add_to_team.rb
@@ -5,7 +5,7 @@ module Schools
class AddToTeam < Dry::CLI::Command
desc "Add an existing school to a team"
- argument :ods_code, required: true, desc: "The ODS code of the team"
+ argument :workgroup, required: true, desc: "The ODS code of the team"
argument :subteam, required: true, desc: "The subteam of the team"
argument :urns,
type: :array,
@@ -16,11 +16,10 @@ class AddToTeam < Dry::CLI::Command
type: :array,
desc: "The programmes administered at the school"
- def call(ods_code:, subteam:, urns:, programmes: [], **)
+ def call(workgroup:, subteam:, urns:, programmes: [], **)
MavisCLI.load_rails
- # TODO: Select the right team based on an identifier.
- team = Team.joins(:organisation).find_by(organisation: { ods_code: })
+ team = Team.find_by(workgroup:)
if team.nil?
warn "Could not find team."
diff --git a/app/lib/mavis_cli/teams/add_programme.rb b/app/lib/mavis_cli/teams/add_programme.rb
index 46959f9540..19b9a63f4e 100644
--- a/app/lib/mavis_cli/teams/add_programme.rb
+++ b/app/lib/mavis_cli/teams/add_programme.rb
@@ -5,25 +5,14 @@ module Teams
class AddProgramme < Dry::CLI::Command
desc "Adds a programme to a team"
- argument :ods_code,
- required: true,
- desc: "The ODS code of the organisation"
-
- argument :name, required: true, desc: "The name of the team"
+ argument :workgroup, required: true, desc: "The workgroup of the team"
argument :type, required: true, desc: "The type of programme to add"
- def call(ods_code:, name:, type:)
+ def call(workgroup:, type:)
MavisCLI.load_rails
- organisation = Organisation.find_by(ods_code:)
-
- if organisation.nil?
- warn "Could not find organisation."
- return
- end
-
- team = organisation.teams.find_by(name:)
+ team = Team.find_by(workgroup:)
if team.nil?
warn "Could not find team."
diff --git a/app/lib/mavis_cli/teams/create_sessions.rb b/app/lib/mavis_cli/teams/create_sessions.rb
index d50416e987..16cc29a341 100644
--- a/app/lib/mavis_cli/teams/create_sessions.rb
+++ b/app/lib/mavis_cli/teams/create_sessions.rb
@@ -5,27 +5,16 @@ module Teams
class CreateSessions < Dry::CLI::Command
desc "Create sessions for all locations"
- argument :ods_code,
- required: true,
- desc: "The ODS code of the organisation"
-
- argument :name, required: true, desc: "The name of the team"
+ argument :workgroup, required: true, desc: "The workgroup of the team"
option :academic_year,
type: :integer,
desc: "The academic year to create the sessions for"
- def call(ods_code:, name:, academic_year: nil)
+ def call(workgroup:, academic_year: nil)
MavisCLI.load_rails
- organisation = Organisation.find_by(ods_code:)
-
- if organisation.nil?
- warn "Could not find organisation."
- return
- end
-
- team = organisation.teams.find_by(name:)
+ team = Team.find_by(workgroup:)
if team.nil?
warn "Could not find team."
diff --git a/app/lib/mavis_cli/teams/list.rb b/app/lib/mavis_cli/teams/list.rb
new file mode 100644
index 0000000000..0880d0b326
--- /dev/null
+++ b/app/lib/mavis_cli/teams/list.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module MavisCLI
+ module Teams
+ class List < Dry::CLI::Command
+ desc "List teams in Mavis"
+
+ option :ods_code,
+ desc: "The ODS code of the organisation to list teams for"
+
+ def call(ods_code: nil)
+ MavisCLI.load_rails
+
+ teams =
+ if ods_code.present?
+ organisation = Organisation.find_by(ods_code:)
+ if organisation.nil?
+ raise ArgumentError,
+ "Could not find organisation with ODS code: #{ods_code}"
+ end
+
+ organisation.teams
+ else
+ Team.all
+ end.map do |team|
+ team.attributes.merge(ods_code: team.organisation.ods_code)
+ end
+
+ puts TableTennis.new(
+ teams,
+ columns: %i[id name ods_code workgroup],
+ zebra: true
+ )
+ end
+ end
+ end
+
+ register "teams" do |prefix|
+ prefix.register "list", Teams::List
+ end
+end
diff --git a/app/lib/mavis_cli/teams/onboard.rb b/app/lib/mavis_cli/teams/onboard.rb
new file mode 100644
index 0000000000..e14cee67b0
--- /dev/null
+++ b/app/lib/mavis_cli/teams/onboard.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module MavisCLI
+ module Teams
+ class Onboard < Dry::CLI::Command
+ desc "Onboard a new team"
+
+ argument :path,
+ required: true,
+ desc: "The path to the onboarding configuration file"
+
+ def call(path:)
+ MavisCLI.load_rails
+
+ config = YAML.safe_load(File.read(path))
+
+ onboarding = Onboarding.new(config)
+
+ if onboarding.valid?
+ onboarding.save!
+ else
+ onboarding.errors.full_messages.each { |message| puts message }
+ end
+ end
+ end
+ end
+
+ register "teams" do |prefix|
+ prefix.register "onboard", Teams::Onboard
+ end
+end
diff --git a/app/lib/nhs/immunisations_api.rb b/app/lib/nhs/immunisations_api.rb
index 70b35f2fdf..e06d00f7e9 100644
--- a/app/lib/nhs/immunisations_api.rb
+++ b/app/lib/nhs/immunisations_api.rb
@@ -189,7 +189,8 @@ def should_be_in_immunisations_api?(
vaccination_record.administered? &&
vaccination_record.programme.type.in?(PROGRAMME_TYPES) &&
(ignore_nhs_number || vaccination_record.patient.nhs_number.present?) &&
- vaccination_record.notify_parents
+ vaccination_record.notify_parents &&
+ vaccination_record.patient.not_invalidated?
end
private
diff --git a/app/lib/reports/offline_session_exporter.rb b/app/lib/reports/offline_session_exporter.rb
index c06ae14961..d08d4e56f1 100644
--- a/app/lib/reports/offline_session_exporter.rb
+++ b/app/lib/reports/offline_session_exporter.rb
@@ -182,7 +182,7 @@ def gillick_assessments
def triages
@triages ||=
Triage
- .select("DISTINCT ON (patient_id, programme_id) triage.*")
+ .select("DISTINCT ON (patient_id, programme_id) triages.*")
.where(academic_year:, patient_id: patient_sessions.select(:patient_id))
.not_invalidated
.order(:patient_id, :programme_id, created_at: :desc)
diff --git a/app/lib/reports/programme_vaccinations_exporter.rb b/app/lib/reports/programme_vaccinations_exporter.rb
index ec35e06a7e..6100f86e1e 100644
--- a/app/lib/reports/programme_vaccinations_exporter.rb
+++ b/app/lib/reports/programme_vaccinations_exporter.rb
@@ -172,7 +172,7 @@ def gillick_assessments
def triages
@triages ||=
Triage
- .select("DISTINCT ON (patient_id) triage.*")
+ .select("DISTINCT ON (patient_id) triages.*")
.where(
academic_year:,
patient_id: vaccination_records.select(:patient_id),
diff --git a/app/lib/status_generator/consent.rb b/app/lib/status_generator/consent.rb
new file mode 100644
index 0000000000..177dd13a28
--- /dev/null
+++ b/app/lib/status_generator/consent.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+class StatusGenerator::Consent
+ def initialize(
+ programme:,
+ academic_year:,
+ patient:,
+ consents:,
+ vaccination_records:
+ )
+ @programme = programme
+ @academic_year = academic_year
+ @patient = patient
+ @consents = consents
+ @vaccination_records = vaccination_records
+ end
+
+ def status
+ if status_should_be_given?
+ :given
+ elsif status_should_be_refused?
+ :refused
+ elsif status_should_be_conflicts?
+ :conflicts
+ elsif status_should_be_no_response?
+ :no_response
+ else
+ :not_required
+ end
+ end
+
+ def vaccine_methods
+ status_should_be_given? ? agreed_vaccine_methods : []
+ end
+
+ private
+
+ attr_reader :programme,
+ :academic_year,
+ :patient,
+ :consents,
+ :vaccination_records
+
+ def vaccinated?
+ @vaccinated ||=
+ VaccinatedCriteria.call(
+ programme:,
+ academic_year:,
+ patient:,
+ vaccination_records:
+ )
+ end
+
+ def status_should_be_given?
+ return false if vaccinated?
+
+ consents_for_status.any? && consents_for_status.all?(&:response_given?) &&
+ agreed_vaccine_methods.present?
+ end
+
+ def status_should_be_refused?
+ return false if vaccinated?
+
+ latest_consents.any? && latest_consents.all?(&:response_refused?)
+ end
+
+ def status_should_be_conflicts?
+ return false if vaccinated?
+
+ consents_for_status =
+ (self_consents.any? ? self_consents : parental_consents)
+
+ if consents_for_status.any?(&:response_refused?) &&
+ consents_for_status.any?(&:response_given?)
+ return true
+ end
+
+ consents_for_status.any? && consents_for_status.all?(&:response_given?) &&
+ agreed_vaccine_methods.blank?
+ end
+
+ def status_should_be_no_response? = !vaccinated?
+
+ def agreed_vaccine_methods
+ @agreed_vaccine_methods ||=
+ consents_for_status.map(&:vaccine_methods).inject(&:intersection)
+ end
+
+ def consents_for_status
+ @consents_for_status ||=
+ self_consents.any? ? self_consents : parental_consents
+ end
+
+ def self_consents
+ @self_consents ||= latest_consents.select(&:via_self_consent?)
+ end
+
+ def parental_consents
+ @parental_consents ||= latest_consents.reject(&:via_self_consent?)
+ end
+
+ def latest_consents
+ @latest_consents ||=
+ ConsentGrouper.call(consents, programme_id: programme.id, academic_year:)
+ end
+end
diff --git a/app/lib/status_generator/registration.rb b/app/lib/status_generator/registration.rb
new file mode 100644
index 0000000000..5bc25b5c16
--- /dev/null
+++ b/app/lib/status_generator/registration.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+class StatusGenerator::Registration
+ def initialize(patient_session:, session_attendance:, vaccination_records:)
+ @patient_session = patient_session
+ @session_attendance = session_attendance
+ @vaccination_records = vaccination_records
+ end
+
+ def status
+ if status_should_be_completed?
+ :completed
+ elsif status_should_be_attending?
+ :attending
+ elsif status_should_be_not_attending?
+ :not_attending
+ else
+ :unknown
+ end
+ end
+
+ private
+
+ attr_reader :patient_session, :session_attendance, :vaccination_records
+
+ def academic_year = patient_session.session.academic_year
+
+ def status_should_be_completed?
+ patient_session.programmes.all? do |programme|
+ vaccination_records.any? do
+ it.programme_id == programme.id &&
+ it.session_id == patient_session.session_id
+ end
+ end
+ end
+
+ def status_should_be_attending?
+ session_attendance&.attending
+ end
+
+ def status_should_be_not_attending?
+ session_attendance&.attending == false
+ end
+end
diff --git a/app/lib/status_generator/session.rb b/app/lib/status_generator/session.rb
new file mode 100644
index 0000000000..49824a8191
--- /dev/null
+++ b/app/lib/status_generator/session.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+class StatusGenerator::Session
+ def initialize(
+ session_id:,
+ academic_year:,
+ session_attendance:,
+ programme_id:,
+ consents:,
+ triages:,
+ vaccination_records:
+ )
+ @session_id = session_id
+ @academic_year = academic_year
+ @session_attendance = session_attendance
+ @programme_id = programme_id
+ @consents = consents
+ @triages = triages
+ @vaccination_records = vaccination_records
+ end
+
+ def status
+ if status_should_be_vaccinated?
+ :vaccinated
+ elsif status_should_be_already_had?
+ :already_had
+ elsif status_should_be_had_contraindications?
+ :had_contraindications
+ elsif status_should_be_refused?
+ :refused
+ elsif status_should_be_absent_from_session?
+ :absent_from_session
+ elsif status_should_be_unwell?
+ :unwell
+ else
+ :none_yet
+ end
+ end
+
+ private
+
+ attr_reader :session_id,
+ :academic_year,
+ :session_attendance,
+ :programme_id,
+ :consents,
+ :triages,
+ :vaccination_records
+
+ def status_should_be_vaccinated?
+ vaccination_record&.administered?
+ end
+
+ def status_should_be_already_had?
+ vaccination_record&.already_had?
+ end
+
+ def status_should_be_had_contraindications?
+ vaccination_record&.contraindications? || triage&.do_not_vaccinate?
+ end
+
+ def status_should_be_refused?
+ vaccination_record&.refused? ||
+ (latest_consents.any? && latest_consents.all?(&:response_refused?))
+ end
+
+ def status_should_be_absent_from_session?
+ vaccination_record&.absent_from_session? ||
+ session_attendance&.attending == false
+ end
+
+ def status_should_be_unwell?
+ vaccination_record&.not_well?
+ end
+
+ def latest_consents
+ @latest_consents ||=
+ ConsentGrouper.call(consents, programme_id:, academic_year:)
+ end
+
+ def triage
+ @triage ||= TriageFinder.call(triages, programme_id:, academic_year:)
+ end
+
+ def vaccination_record
+ @vaccination_record ||=
+ vaccination_records.find do
+ it.programme_id == programme_id && it.session_id == session_id
+ end
+ end
+end
diff --git a/app/lib/status_generator/triage.rb b/app/lib/status_generator/triage.rb
new file mode 100644
index 0000000000..89679bd528
--- /dev/null
+++ b/app/lib/status_generator/triage.rb
@@ -0,0 +1,118 @@
+# frozen_string_literal: true
+
+class StatusGenerator::Triage
+ def initialize(
+ programme:,
+ academic_year:,
+ patient:,
+ consents:,
+ triages:,
+ vaccination_records:
+ )
+ @programme = programme
+ @academic_year = academic_year
+ @patient = patient
+ @consents = consents
+ @triages = triages
+ @vaccination_records = vaccination_records
+ end
+
+ def status
+ if status_should_be_safe_to_vaccinate?
+ :safe_to_vaccinate
+ elsif status_should_be_do_not_vaccinate?
+ :do_not_vaccinate
+ elsif status_should_be_delay_vaccination?
+ :delay_vaccination
+ elsif status_should_be_required?
+ :required
+ else
+ :not_required
+ end
+ end
+
+ def vaccine_method
+ latest_triage&.vaccine_method if status_should_be_safe_to_vaccinate?
+ end
+
+ def consent_requires_triage?
+ latest_consents.any?(&:requires_triage?)
+ end
+
+ def vaccination_history_requires_triage?
+ existing_records =
+ vaccination_records.select { it.programme_id == programme_id }
+
+ if programme.seasonal?
+ existing_records.select! { it.academic_year == academic_year }
+ end
+
+ existing_records.any?(&:administered?) && !vaccinated?
+ end
+
+ private
+
+ attr_reader :programme,
+ :academic_year,
+ :patient,
+ :consents,
+ :triages,
+ :vaccination_records
+
+ def programme_id = programme.id
+
+ def vaccinated?
+ @vaccinated ||=
+ VaccinatedCriteria.call(
+ programme:,
+ academic_year:,
+ patient:,
+ vaccination_records:
+ )
+ end
+
+ def status_should_be_safe_to_vaccinate?
+ return false if vaccinated?
+ latest_triage&.ready_to_vaccinate?
+ end
+
+ def status_should_be_do_not_vaccinate?
+ return false if vaccinated?
+ latest_triage&.do_not_vaccinate?
+ end
+
+ def status_should_be_delay_vaccination?
+ return false if vaccinated?
+ latest_triage&.delay_vaccination?
+ end
+
+ def status_should_be_required?
+ return false if vaccinated?
+ return true if latest_triage&.needs_follow_up?
+
+ return false if latest_consents.empty?
+
+ consent_generator.status == :given &&
+ (consent_requires_triage? || vaccination_history_requires_triage?)
+ end
+
+ def consent_generator
+ @consent_generator ||=
+ StatusGenerator::Consent.new(
+ programme:,
+ academic_year:,
+ patient:,
+ consents:,
+ vaccination_records:
+ )
+ end
+
+ def latest_consents
+ @latest_consents ||=
+ ConsentGrouper.call(consents, programme_id:, academic_year:)
+ end
+
+ def latest_triage
+ @latest_triage ||= TriageFinder.call(triages, programme_id:, academic_year:)
+ end
+end
diff --git a/app/lib/status_generator/vaccination.rb b/app/lib/status_generator/vaccination.rb
new file mode 100644
index 0000000000..887feccb9d
--- /dev/null
+++ b/app/lib/status_generator/vaccination.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+class StatusGenerator::Vaccination
+ def initialize(
+ programme:,
+ academic_year:,
+ patient:,
+ consents:,
+ triages:,
+ vaccination_records:
+ )
+ @programme = programme
+ @academic_year = academic_year
+ @patient = patient
+ @consents = consents
+ @triages = triages
+ @vaccination_records = vaccination_records
+ end
+
+ def status
+ if status_should_be_vaccinated?
+ :vaccinated
+ elsif status_should_be_could_not_vaccinate?
+ :could_not_vaccinate
+ else
+ :none_yet
+ end
+ end
+
+ private
+
+ attr_reader :programme,
+ :academic_year,
+ :patient,
+ :consents,
+ :triages,
+ :vaccination_records
+
+ def programme_id = programme.id
+
+ def status_should_be_vaccinated?
+ VaccinatedCriteria.call(
+ programme:,
+ academic_year:,
+ patient:,
+ vaccination_records:
+ )
+ end
+
+ def status_should_be_could_not_vaccinate?
+ if ConsentGrouper.call(consents, programme_id:, academic_year:).any?(
+ &:response_refused?
+ )
+ return true
+ end
+
+ TriageFinder.call(triages, programme_id:, academic_year:)&.do_not_vaccinate?
+ end
+end
diff --git a/app/lib/status_updater.rb b/app/lib/status_updater.rb
index 7f8ae66c44..6c96f90c76 100644
--- a/app/lib/status_updater.rb
+++ b/app/lib/status_updater.rb
@@ -35,7 +35,7 @@ def update_consent_statuses!
Patient::ConsentStatus
.where(patient: patient_sessions.select(:patient_id))
- .includes(:consents)
+ .includes(:consents, :patient, :programme, :vaccination_records)
.find_in_batches(batch_size: 10_000) do |batch|
batch.each(&:assign_status)
@@ -59,10 +59,11 @@ def update_registration_statuses!
PatientSession::RegistrationStatus
.where(patient_session_id: patient_sessions.select(:id))
.includes(
- :patient,
:session_attendance,
:vaccination_records,
- session: :programmes
+ patient_session: {
+ session: :programmes
+ }
)
.find_in_batches(batch_size: 10_000) do |batch|
batch.each(&:assign_status)
@@ -138,7 +139,14 @@ def update_vaccination_statuses!
Patient::VaccinationStatus
.where(patient: patient_sessions.select(:patient_id))
- .includes(:patient, :programme, :consents, :triages, :vaccination_records)
+ .includes(
+ :patient,
+ :programme,
+ :consents,
+ :triages,
+ :vaccination_records,
+ :session_attendance
+ )
.find_in_batches(batch_size: 10_000) do |batch|
batch.each(&:assign_status)
@@ -146,7 +154,7 @@ def update_vaccination_statuses!
batch.select(&:changed?),
on_duplicate_key_update: {
conflict_target: [:id],
- columns: %i[status]
+ columns: %i[status latest_session_status]
}
)
end
diff --git a/app/models/concerns/request_session_persistable.rb b/app/models/concerns/request_session_persistable.rb
index 9e35fa8023..bccc5bf143 100644
--- a/app/models/concerns/request_session_persistable.rb
+++ b/app/models/concerns/request_session_persistable.rb
@@ -41,8 +41,14 @@ def assign_attributes(new_attributes)
end
end
+ def reset_unused_attributes
+ # This can be overridden to provide a before_save callback which can be
+ # used to clear any responses from branching questions where the user has
+ # gone back and edited their answers meaning they're no longer relevant.
+ end
+
def save(context: :update)
- reset_unused_fields
+ reset_unused_attributes
return false if invalid?(context)
@request_session[request_session_key] = attributes.each_with_object(
@@ -82,8 +88,12 @@ def []=(attr, value)
public_send("#{attr}=", value)
end
- def reset!
+ def clear_attributes
attribute_names.each { |attribute| self[attribute] = nil }
+ end
+
+ def clear!
+ clear_attributes
save!(context: :create)
end
diff --git a/app/models/consent_form.rb b/app/models/consent_form.rb
index 2004765ff5..459a36a7fa 100644
--- a/app/models/consent_form.rb
+++ b/app/models/consent_form.rb
@@ -66,7 +66,7 @@ class ConsentForm < ApplicationRecord
include HasHealthAnswers
include WizardStepConcern
- before_save :reset_unused_fields
+ before_save :reset_unused_attributes
scope :unmatched, -> { where(consent_id: nil) }
scope :recorded, -> { where.not(recorded_at: nil) }
@@ -369,7 +369,11 @@ def actual_session
(location_is_clinic? && original_session) ||
(
school &&
- school.sessions.includes(:session_dates).find_by(academic_year:)
+ school
+ .sessions
+ .has_programmes(programmes)
+ .includes(:session_dates)
+ .find_by(academic_year:)
) || team.generic_clinic_session(academic_year:)
end
@@ -583,10 +587,7 @@ def choose_school?
location_is_clinic? ? education_setting_school? : !school_confirmed
end
- # Because there are branching paths in the consent form journey, fields
- # sometimes get set with values that then have to be deleted if the user
- # changes their mind and goes down a different path.
- def reset_unused_fields
+ def reset_unused_attributes
update_programme_responses
unless use_preferred_name
diff --git a/app/models/draft_consent.rb b/app/models/draft_consent.rb
index 2a35974640..9ad290140a 100644
--- a/app/models/draft_consent.rb
+++ b/app/models/draft_consent.rb
@@ -459,7 +459,7 @@ def health_answers_are_valid
def request_session_key = "consent"
- def reset_unused_fields
+ def reset_unused_attributes
update_vaccine_methods
self.notes = "" unless notes_required?
diff --git a/app/models/draft_import.rb b/app/models/draft_import.rb
index 023a09bb69..eb2ec4e0b1 100644
--- a/app/models/draft_import.rb
+++ b/app/models/draft_import.rb
@@ -70,7 +70,7 @@ def academic_year_values = [AcademicYear.current, AcademicYear.pending].uniq
def request_session_key = "import"
- def reset_unused_fields
+ def reset_unused_attributes
self.academic_year = AcademicYear.pending unless ask_academic_year?
unless is_class_import?
diff --git a/app/models/draft_vaccination_record.rb b/app/models/draft_vaccination_record.rb
index 003e0d1c14..36860eb135 100644
--- a/app/models/draft_vaccination_record.rb
+++ b/app/models/draft_vaccination_record.rb
@@ -283,7 +283,7 @@ def writable_attribute_names
def request_session_key = "vaccination_record"
- def reset_unused_fields
+ def reset_unused_attributes
if administered?
self.full_dose = true unless can_be_half_dose?
else
diff --git a/app/models/onboarding.rb b/app/models/onboarding.rb
index 59f67ac50f..fd2bf3d2ce 100644
--- a/app/models/onboarding.rb
+++ b/app/models/onboarding.rb
@@ -28,6 +28,7 @@ class Onboarding
privacy_notice_url
privacy_policy_url
reply_to_id
+ workgroup
].freeze
SUBTEAM_ATTRIBUTES = %i[
diff --git a/app/models/parent.rb b/app/models/parent.rb
index 04b8f832a4..5e17d903e4 100644
--- a/app/models/parent.rb
+++ b/app/models/parent.rb
@@ -21,7 +21,7 @@
class Parent < ApplicationRecord
audited
- before_save :reset_unused_fields
+ before_save :reset_unused_attributes
has_many :consents
has_many :notify_log_entries, dependent: :nullify
@@ -112,7 +112,7 @@ def sms_delivery_status
private
- def reset_unused_fields
+ def reset_unused_attributes
self.contact_method_type = nil if phone.blank?
self.contact_method_other_details = nil unless contact_method_other?
end
diff --git a/app/models/patient.rb b/app/models/patient.rb
index b4a7a55f72..513286de7f 100644
--- a/app/models/patient.rb
+++ b/app/models/patient.rb
@@ -124,19 +124,14 @@ class Patient < ApplicationRecord
scope :has_vaccination_records_dont_notify_parents,
-> do
- joins(:vaccination_records).where(
- vaccination_records: {
- notify_parents: false
- }
- ).distinct
- end
-
- scope :with_notice,
- -> do
- (
- deceased + restricted + invalidated +
- has_vaccination_records_dont_notify_parents
- ).uniq
+ where(
+ VaccinationRecord
+ .kept
+ .where("patient_id = patients.id")
+ .where(notify_parents: false)
+ .arel
+ .exists
+ )
end
scope :appear_in_programmes,
@@ -367,6 +362,12 @@ def show_year_group?(team:)
end
end
+ def in_generic_clinic?(team:, academic_year: nil)
+ academic_year ||= AcademicYear.pending
+ session = team.generic_clinic_session(academic_year:)
+ patient_sessions.exists?(session:)
+ end
+
def consent_status(programme:, academic_year:)
patient_status(consent_statuses, programme:, academic_year:)
end
@@ -473,7 +474,12 @@ def invalidate!
update!(invalidated_at: Time.current)
end
- def not_in_team? = patient_sessions.empty?
+ def not_in_team?(team:, academic_year:)
+ patient_sessions
+ .joins(:session)
+ .where(session: { academic_year:, team: })
+ .empty?
+ end
def dup_for_pending_changes
dup.tap do |new_patient|
@@ -564,8 +570,12 @@ def archive_due_to_deceased!
def fhir_mapper = @fhir_mapper ||= FHIRMapper::Patient.new(self)
+ def should_sync_vaccinations_to_nhs_immunisations_api?
+ nhs_number_previously_changed? || invalidated_at_previously_changed?
+ end
+
def sync_vaccinations_to_nhs_immunisations_api
- if nhs_number_previously_changed?
+ if should_sync_vaccinations_to_nhs_immunisations_api?
vaccination_records.syncable_to_nhs_immunisations_api.find_each(
&:sync_to_nhs_immunisations_api
)
diff --git a/app/models/patient/consent_status.rb b/app/models/patient/consent_status.rb
index adf75c4911..988f77d7cb 100644
--- a/app/models/patient/consent_status.rb
+++ b/app/models/patient/consent_status.rb
@@ -31,79 +31,39 @@ class Patient::ConsentStatus < ApplicationRecord
-> { not_invalidated.response_provided.includes(:parent, :patient) },
through: :patient
+ has_many :vaccination_records,
+ -> { kept.order(performed_at: :desc) },
+ through: :patient
+
scope :has_vaccine_method,
->(vaccine_method) do
where("vaccine_methods[1] = ?", vaccine_methods.fetch(vaccine_method))
end
enum :status,
- { no_response: 0, given: 1, refused: 2, conflicts: 3 },
+ { no_response: 0, given: 1, refused: 2, conflicts: 3, not_required: 4 },
default: :no_response,
validate: true
validates :vaccine_methods, presence: true, if: :given?
def assign_status
- self.status =
- if status_should_be_given?
- :given
- elsif status_should_be_refused?
- :refused
- elsif status_should_be_conflicts?
- :conflicts
- else
- :no_response
- end
-
- self.vaccine_methods = (agreed_vaccine_methods if status_should_be_given?)
+ self.status = generator.status
+ self.vaccine_methods = generator.vaccine_methods
end
def vaccine_method_nasal? = vaccine_methods.include?("nasal")
private
- def status_should_be_given?
- consents_for_status.any? && consents_for_status.all?(&:response_given?) &&
- agreed_vaccine_methods.present?
- end
-
- def status_should_be_refused?
- latest_consents.any? && latest_consents.all?(&:response_refused?)
- end
-
- def status_should_be_conflicts?
- consents_for_status =
- (self_consents.any? ? self_consents : parental_consents)
-
- if consents_for_status.any?(&:response_refused?) &&
- consents_for_status.any?(&:response_given?)
- return true
- end
-
- consents_for_status.any? && consents_for_status.all?(&:response_given?) &&
- agreed_vaccine_methods.blank?
- end
-
- def agreed_vaccine_methods
- @agreed_vaccine_methods ||=
- consents_for_status.map(&:vaccine_methods).inject(&:intersection)
- end
-
- def consents_for_status
- @consents_for_status ||=
- self_consents.any? ? self_consents : parental_consents
- end
-
- def self_consents
- @self_consents ||= latest_consents.select(&:via_self_consent?)
- end
-
- def parental_consents
- @parental_consents ||= latest_consents.reject(&:via_self_consent?)
- end
-
- def latest_consents
- @latest_consents ||=
- ConsentGrouper.call(consents, programme_id:, academic_year:)
+ def generator
+ @generator ||=
+ StatusGenerator::Consent.new(
+ programme:,
+ academic_year:,
+ patient:,
+ consents:,
+ vaccination_records:
+ )
end
end
diff --git a/app/models/patient/triage_status.rb b/app/models/patient/triage_status.rb
index acb37f48ac..b99dd362fd 100644
--- a/app/models/patient/triage_status.rb
+++ b/app/models/patient/triage_status.rb
@@ -56,91 +56,25 @@ class Patient::TriageStatus < ApplicationRecord
}
def assign_status
- self.status =
- if status_should_be_safe_to_vaccinate?
- :safe_to_vaccinate
- elsif status_should_be_do_not_vaccinate?
- :do_not_vaccinate
- elsif status_should_be_delay_vaccination?
- :delay_vaccination
- elsif status_should_be_required?
- :required
- else
- :not_required
- end
-
- self.vaccine_method =
- (latest_triage&.vaccine_method if status_should_be_safe_to_vaccinate?)
- end
-
- def consent_requires_triage?
- latest_consents.any?(&:requires_triage?)
+ self.status = generator.status
+ self.vaccine_method = generator.vaccine_method
end
- def vaccination_history_requires_triage?
- existing_records =
- vaccination_records.select { it.programme_id == programme_id }
-
- if programme.seasonal?
- existing_records.select! { it.academic_year == academic_year }
- end
-
- existing_records.any?(&:administered?) && !vaccinated?
- end
+ delegate :consent_requires_triage?,
+ :vaccination_history_requires_triage?,
+ to: :generator
private
- def vaccinated?
- @vaccinated ||=
- VaccinatedCriteria.call(
+ def generator
+ @generator ||=
+ StatusGenerator::Triage.new(
programme:,
academic_year:,
patient:,
+ consents:,
+ triages:,
vaccination_records:
)
end
-
- def status_should_be_safe_to_vaccinate?
- return false if vaccinated?
- latest_triage&.ready_to_vaccinate?
- end
-
- def status_should_be_do_not_vaccinate?
- return false if vaccinated?
- latest_triage&.do_not_vaccinate?
- end
-
- def status_should_be_delay_vaccination?
- return false if vaccinated?
- latest_triage&.delay_vaccination?
- end
-
- def status_should_be_required?
- return false if vaccinated?
- return true if latest_triage&.needs_follow_up?
-
- return false if latest_consents.empty?
-
- consent_status.given? &&
- (consent_requires_triage? || vaccination_history_requires_triage?)
- end
-
- def consent_status
- @consent_status ||=
- Patient::ConsentStatus.new(
- patient_id:,
- programme_id:,
- academic_year:,
- consents:
- ).tap(&:assign_status)
- end
-
- def latest_consents
- @latest_consents ||=
- ConsentGrouper.call(consents, programme_id:, academic_year:)
- end
-
- def latest_triage
- @latest_triage ||= TriageFinder.call(triages, programme_id:, academic_year:)
- end
end
diff --git a/app/models/patient/vaccination_status.rb b/app/models/patient/vaccination_status.rb
index c215b80251..20591c1cd4 100644
--- a/app/models/patient/vaccination_status.rb
+++ b/app/models/patient/vaccination_status.rb
@@ -4,11 +4,12 @@
#
# Table name: patient_vaccination_statuses
#
-# id :bigint not null, primary key
-# academic_year :integer not null
-# status :integer default("none_yet"), not null
-# patient_id :bigint not null
-# programme_id :bigint not null
+# id :bigint not null, primary key
+# academic_year :integer not null
+# latest_session_status :integer default("none_yet"), not null
+# status :integer default("none_yet"), not null
+# patient_id :bigint not null
+# programme_id :bigint not null
#
# Indexes
#
@@ -36,40 +37,55 @@ class Patient::VaccinationStatus < ApplicationRecord
-> { kept.order(performed_at: :desc) },
through: :patient
+ has_one :patient_session
+
+ has_one :session_attendance,
+ -> { today },
+ through: :patient,
+ source: :session_attendances
+
enum :status,
{ none_yet: 0, vaccinated: 1, could_not_vaccinate: 2 },
default: :none_yet,
validate: true
+ enum :latest_session_status,
+ PatientSession::SessionStatus.statuses,
+ default: :none_yet,
+ prefix: true,
+ validate: true
+
def assign_status
- self.status =
- if status_should_be_vaccinated?
- :vaccinated
- elsif status_should_be_could_not_vaccinate?
- :could_not_vaccinate
- else
- :none_yet
- end
+ self.status = generator.status
+ self.latest_session_status = session_generator&.status || :none_yet
end
private
- def status_should_be_vaccinated?
- VaccinatedCriteria.call(
- programme:,
- academic_year:,
- patient:,
- vaccination_records:
- )
+ def generator
+ @generator ||=
+ StatusGenerator::Vaccination.new(
+ programme:,
+ academic_year:,
+ patient:,
+ consents:,
+ triages:,
+ vaccination_records:
+ )
end
- def status_should_be_could_not_vaccinate?
- if ConsentGrouper.call(consents, programme_id:, academic_year:).any?(
- &:response_refused?
- )
- return true
- end
-
- TriageFinder.call(triages, programme_id:, academic_year:)&.do_not_vaccinate?
+ def session_generator
+ @session_generator ||=
+ if (session_id = vaccination_records.first&.session_id)
+ StatusGenerator::Session.new(
+ session_id:,
+ academic_year:,
+ session_attendance:,
+ programme_id:,
+ consents:,
+ triages:,
+ vaccination_records:
+ )
+ end
end
end
diff --git a/app/models/patient_import.rb b/app/models/patient_import.rb
index dca92833dd..024e8dc008 100644
--- a/app/models/patient_import.rb
+++ b/app/models/patient_import.rb
@@ -33,7 +33,8 @@ def process_row(row)
if (school_move = row.to_school_move(patient))
if (patient.school.nil? && !patient.home_educated) ||
- patient.not_in_team? || patient.archived?(team:)
+ patient.not_in_team?(team:, academic_year:) ||
+ patient.archived?(team:)
@school_moves_to_confirm.add(school_move)
else
@school_moves_to_save.add(school_move)
diff --git a/app/models/patient_import_row.rb b/app/models/patient_import_row.rb
index 4a8c6f26ed..253021d13a 100644
--- a/app/models/patient_import_row.rb
+++ b/app/models/patient_import_row.rb
@@ -42,8 +42,8 @@ def to_school_move(patient)
return if patient.deceased?
if patient.new_record? || patient.school != school ||
- patient.home_educated != home_educated || patient.not_in_team? ||
- patient.archived?(team:)
+ patient.home_educated != home_educated ||
+ patient.not_in_team?(team:, academic_year:) || patient.archived?(team:)
school_move =
if school
SchoolMove.find_or_initialize_by(patient:, school:)
diff --git a/app/models/patient_session.rb b/app/models/patient_session.rb
index fbb4eea540..01cdfd31ca 100644
--- a/app/models/patient_session.rb
+++ b/app/models/patient_session.rb
@@ -94,8 +94,6 @@ class PatientSession < ApplicationRecord
scope :appear_in_programmes,
->(programmes) do
- age_children_start_school = 5
-
# Is the patient eligible for any of those programmes by year group?
location_programme_year_groups =
LocationProgrammeYearGroup
@@ -104,7 +102,7 @@ class PatientSession < ApplicationRecord
.where(
"year_group = sessions.academic_year " \
"- patients.birth_academic_year " \
- "- #{age_children_start_school}"
+ "- #{Integer::AGE_CHILDREN_START_SCHOOL}"
)
# Are any of the programmes administered in the session?
@@ -206,6 +204,18 @@ class PatientSession < ApplicationRecord
)
end
+ scope :has_vaccination_status,
+ ->(status, programme:) do
+ joins(:session).where(
+ Patient::VaccinationStatus
+ .where("patient_id = patient_sessions.patient_id")
+ .where("academic_year = sessions.academic_year")
+ .where(status:, programme:)
+ .arel
+ .exists
+ )
+ end
+
scope :has_vaccine_method,
->(vaccine_method, programme:) do
joins(:session).where(
diff --git a/app/models/patient_session/registration_status.rb b/app/models/patient_session/registration_status.rb
index bd120c310d..d04835b53e 100644
--- a/app/models/patient_session/registration_status.rb
+++ b/app/models/patient_session/registration_status.rb
@@ -21,7 +21,6 @@ class PatientSession::RegistrationStatus < ApplicationRecord
belongs_to :patient_session
has_one :patient, through: :patient_session
- has_one :session, through: :patient_session
has_many :vaccination_records,
-> { kept.order(performed_at: :desc) },
@@ -38,36 +37,17 @@ class PatientSession::RegistrationStatus < ApplicationRecord
validate: true
def assign_status
- self.status =
- if status_should_be_completed?
- :completed
- elsif status_should_be_attending?
- :attending
- elsif status_should_be_not_attending?
- :not_attending
- else
- :unknown
- end
+ self.status = generator.status
end
private
- delegate :academic_year, to: :session
-
- def status_should_be_completed?
- patient_session.programmes.all? do |programme|
- vaccination_records.any? do
- it.programme_id == programme.id &&
- it.session_id == patient_session.session_id
- end
- end
- end
-
- def status_should_be_attending?
- session_attendance&.attending
- end
-
- def status_should_be_not_attending?
- session_attendance&.attending == false
+ def generator
+ @generator ||=
+ StatusGenerator::Registration.new(
+ patient_session:,
+ session_attendance:,
+ vaccination_records:
+ )
end
end
diff --git a/app/models/patient_session/session_status.rb b/app/models/patient_session/session_status.rb
index 1f44a273ce..08187ec711 100644
--- a/app/models/patient_session/session_status.rb
+++ b/app/models/patient_session/session_status.rb
@@ -57,68 +57,21 @@ class PatientSession::SessionStatus < ApplicationRecord
validate: true
def assign_status
- self.status =
- if status_should_be_vaccinated?
- :vaccinated
- elsif status_should_be_already_had?
- :already_had
- elsif status_should_be_had_contraindications?
- :had_contraindications
- elsif status_should_be_refused?
- :refused
- elsif status_should_be_absent_from_session?
- :absent_from_session
- elsif status_should_be_unwell?
- :unwell
- else
- :none_yet
- end
+ self.status = generator.status
end
private
- delegate :academic_year, to: :session
-
- def status_should_be_vaccinated?
- vaccination_record&.administered?
- end
-
- def status_should_be_already_had?
- vaccination_record&.already_had?
- end
-
- def status_should_be_had_contraindications?
- vaccination_record&.contraindications? || triage&.do_not_vaccinate?
- end
-
- def status_should_be_refused?
- vaccination_record&.refused? ||
- (latest_consents.any? && latest_consents.all?(&:response_refused?))
- end
-
- def status_should_be_absent_from_session?
- vaccination_record&.absent_from_session? ||
- session_attendance&.attending == false
- end
-
- def status_should_be_unwell?
- vaccination_record&.not_well?
- end
-
- def latest_consents
- @latest_consents ||=
- ConsentGrouper.call(consents, programme_id:, academic_year:)
- end
-
- def triage
- @triage ||= TriageFinder.call(triages, programme_id:, academic_year:)
- end
-
- def vaccination_record
- @vaccination_record ||=
- vaccination_records.find do
- it.programme_id == programme.id &&
- it.session_id == patient_session.session_id
- end
+ def generator
+ @generator ||=
+ StatusGenerator::Session.new(
+ session_id: session.id,
+ academic_year: session.academic_year,
+ session_attendance:,
+ programme_id:,
+ consents:,
+ triages:,
+ vaccination_records:
+ )
end
end
diff --git a/app/models/reporting_api.rb b/app/models/reporting_api.rb
new file mode 100644
index 0000000000..f6b0791d24
--- /dev/null
+++ b/app/models/reporting_api.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module ReportingAPI
+ def self.table_name_prefix
+ "reporting_api_"
+ end
+end
diff --git a/app/models/reporting_api/one_time_token.rb b/app/models/reporting_api/one_time_token.rb
new file mode 100644
index 0000000000..89203d83e7
--- /dev/null
+++ b/app/models/reporting_api/one_time_token.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: reporting_api_one_time_tokens
+#
+# cis2_info :jsonb not null
+# token :string not null, primary key
+# created_at :datetime not null
+# updated_at :datetime not null
+# user_id :bigint not null
+#
+# Indexes
+#
+# index_reporting_api_one_time_tokens_on_created_at (created_at)
+# index_reporting_api_one_time_tokens_on_token (token) UNIQUE
+# index_reporting_api_one_time_tokens_on_user_id (user_id) UNIQUE
+#
+# Foreign Keys
+#
+# fk_rails_... (user_id => users.id)
+#
+class ReportingAPI::OneTimeToken < ApplicationRecord
+ belongs_to :user
+
+ validates :user_id, uniqueness: true, presence: true
+ validates :token, uniqueness: true, presence: true
+
+ def self.generate!(user_id:, cis2_info: {})
+ create!(user_id: user_id, token: SecureRandom.hex(32), cis2_info: cis2_info)
+ end
+
+ def self.expire_before
+ Settings.reporting_api.client_app.token_ttl_seconds.seconds.ago
+ end
+
+ def self.find_or_generate_for!(user:, cis2_info: {})
+ transaction do
+ token = find_by(user_id: user.id)
+ token.delete if token&.expired?
+
+ token&.persisted? ? token : generate!(user_id: user.id, cis2_info:)
+ end
+ end
+
+ def expired?
+ created_at < self.class.expire_before
+ end
+end
diff --git a/app/models/school_move.rb b/app/models/school_move.rb
index 7717b8cc28..f31a303eac 100644
--- a/app/models/school_move.rb
+++ b/app/models/school_move.rb
@@ -113,14 +113,7 @@ def sessions_to_add
)
if school
- scope.where(team: school.team, location: school).or(
- scope.where(
- team: school.team,
- locations: {
- type: "generic_clinic"
- }
- )
- )
+ scope.where(team: school.team, location: school)
else
scope.where(team:, locations: { type: "generic_clinic" })
end
diff --git a/app/models/school_move_export.rb b/app/models/school_move_export.rb
index 57721a7942..877f8b7992 100644
--- a/app/models/school_move_export.rb
+++ b/app/models/school_move_export.rb
@@ -55,7 +55,4 @@ def exporter
end
def request_session_key = "school_move_export"
-
- def reset_unused_fields
- end
end
diff --git a/app/models/team.rb b/app/models/team.rb
index 8ea468d5ac..0c351530b9 100644
--- a/app/models/team.rb
+++ b/app/models/team.rb
@@ -15,6 +15,7 @@
# phone_instructions :string
# privacy_notice_url :string not null
# privacy_policy_url :string not null
+# workgroup :string not null
# created_at :datetime not null
# updated_at :datetime not null
# organisation_id :bigint not null
@@ -24,6 +25,7 @@
#
# index_teams_on_name (name) UNIQUE
# index_teams_on_organisation_id (organisation_id)
+# index_teams_on_workgroup (workgroup) UNIQUE
#
# Foreign Keys
#
@@ -31,7 +33,6 @@
#
class Team < ApplicationRecord
include HasProgrammeYearGroups
- include ODSCodeConcern
audited associated_with: :organisation
has_associated_audits
@@ -73,6 +74,7 @@ class Team < ApplicationRecord
validates :phone, presence: true, phone: true
validates :privacy_notice_url, presence: true
validates :privacy_policy_url, presence: true
+ validates :workgroup, presence: true, uniqueness: true
def year_groups
@year_groups ||= location_programme_year_groups.pluck_year_groups
@@ -82,7 +84,12 @@ def generic_clinic_session(academic_year:)
location = locations.generic_clinic.first
sessions
- .includes(:location, :programmes, :session_dates)
+ .includes(
+ :location,
+ :location_programme_year_groups,
+ :programmes,
+ :session_dates
+ )
.create_with(programmes:)
.find_or_create_by!(academic_year:, location:)
end
diff --git a/app/models/triage.rb b/app/models/triage.rb
index 3d4c38d43b..45e1408342 100644
--- a/app/models/triage.rb
+++ b/app/models/triage.rb
@@ -2,7 +2,7 @@
# == Schema Information
#
-# Table name: triage
+# Table name: triages
#
# id :bigint not null, primary key
# academic_year :integer not null
@@ -19,11 +19,11 @@
#
# Indexes
#
-# index_triage_on_academic_year (academic_year)
-# index_triage_on_patient_id (patient_id)
-# index_triage_on_performed_by_user_id (performed_by_user_id)
-# index_triage_on_programme_id (programme_id)
-# index_triage_on_team_id (team_id)
+# index_triages_on_academic_year (academic_year)
+# index_triages_on_patient_id (patient_id)
+# index_triages_on_performed_by_user_id (performed_by_user_id)
+# index_triages_on_programme_id (programme_id)
+# index_triages_on_team_id (team_id)
#
# Foreign Keys
#
@@ -35,8 +35,6 @@
class Triage < ApplicationRecord
include Invalidatable
- self.table_name = "triage"
-
audited associated_with: :patient
belongs_to :patient
diff --git a/app/models/user.rb b/app/models/user.rb
index 31364ff300..62aaf9f6d7 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -4,28 +4,30 @@
#
# Table name: users
#
-# id :bigint not null, primary key
-# current_sign_in_at :datetime
-# current_sign_in_ip :string
-# email :string
-# encrypted_password :string default(""), not null
-# fallback_role :integer default("nurse"), not null
-# family_name :string not null
-# given_name :string not null
-# last_sign_in_at :datetime
-# last_sign_in_ip :string
-# provider :string
-# remember_created_at :datetime
-# session_token :string
-# sign_in_count :integer default(0), not null
-# uid :string
-# created_at :datetime not null
-# updated_at :datetime not null
+# id :bigint not null, primary key
+# current_sign_in_at :datetime
+# current_sign_in_ip :string
+# email :string
+# encrypted_password :string default(""), not null
+# fallback_role :integer default("nurse"), not null
+# family_name :string not null
+# given_name :string not null
+# last_sign_in_at :datetime
+# last_sign_in_ip :string
+# provider :string
+# remember_created_at :datetime
+# reporting_api_session_token :string
+# session_token :string
+# sign_in_count :integer default(0), not null
+# uid :string
+# created_at :datetime not null
+# updated_at :datetime not null
#
# Indexes
#
-# index_users_on_email (email) UNIQUE
-# index_users_on_provider_and_uid (provider,uid) UNIQUE
+# index_users_on_email (email) UNIQUE
+# index_users_on_provider_and_uid (provider,uid) UNIQUE
+# index_users_on_reporting_api_session_token (reporting_api_session_token) UNIQUE
#
class User < ApplicationRecord
include FullNameConcern
@@ -47,6 +49,9 @@ class User < ApplicationRecord
has_many :programmes, through: :teams
+ has_one :reporting_api_one_time_token,
+ class_name: "ReportingAPI::OneTimeToken"
+
encrypts :email, deterministic: true
encrypts :family_name, :given_name
diff --git a/app/models/vaccination_report.rb b/app/models/vaccination_report.rb
index d7f0634de2..077581def6 100644
--- a/app/models/vaccination_report.rb
+++ b/app/models/vaccination_report.rb
@@ -73,7 +73,4 @@ def exporter_class
end
def request_session_key = "vaccination_report"
-
- def reset_unused_fields
- end
end
diff --git a/app/policies/session_attendance_policy.rb b/app/policies/session_attendance_policy.rb
index ad80bd5c61..f58d94285e 100644
--- a/app/policies/session_attendance_policy.rb
+++ b/app/policies/session_attendance_policy.rb
@@ -2,17 +2,28 @@
class SessionAttendancePolicy < ApplicationPolicy
def create?
- super && !was_seen_by_nurse?
+ super && !already_vaccinated? && !was_seen_by_nurse?
end
def update?
- super && !was_seen_by_nurse?
+ super && !already_vaccinated? && !was_seen_by_nurse?
end
private
delegate :patient_session, :session_date, to: :record
+ def academic_year = patient_session.session.academic_year
+
+ def already_vaccinated?
+ patient_session.programmes.all? do |programme|
+ patient_session
+ .patient
+ .vaccination_status(programme:, academic_year:)
+ .vaccinated?
+ end
+ end
+
def was_seen_by_nurse?
VaccinationRecord.kept.exists?(
patient_id: patient_session.patient_id,
diff --git a/app/views/consent_forms/search.html.erb b/app/views/consent_forms/search.html.erb
index 02b0b2133b..29afd27b4b 100644
--- a/app/views/consent_forms/search.html.erb
+++ b/app/views/consent_forms/search.html.erb
@@ -43,7 +43,12 @@
+ <%= render AppSessionNeedsReviewWarningComponent.new(session: @session) %>
<%= render AppSearchResultsComponent.new(@pagy, label: "children") do %>
<% @patient_sessions.each do |patient_session| %>
<%= render AppPatientSessionSearchResultCardComponent.new(
diff --git a/app/views/sessions/outcome/show.html.erb b/app/views/sessions/patients/show.html.erb
similarity index 79%
rename from app/views/sessions/outcome/show.html.erb
rename to app/views/sessions/patients/show.html.erb
index f973984476..905dc6774d 100644
--- a/app/views/sessions/outcome/show.html.erb
+++ b/app/views/sessions/patients/show.html.erb
@@ -12,18 +12,20 @@
<%= render AppPatientSearchFormComponent.new(
@form,
- url: session_outcome_path(@session),
+ url: session_patients_path(@session),
programmes: @session.programmes,
+ programme_statuses: Patient::VaccinationStatus.statuses.keys,
session_statuses: @statuses,
year_groups: @session.year_groups,
) %>
+ <%= render AppSessionNeedsReviewWarningComponent.new(session: @session) %>
<%= render AppSearchResultsComponent.new(@pagy, label: "children") do %>
<% @patient_sessions.each do |patient_session| %>
<%= render AppPatientSessionSearchResultCardComponent.new(
- patient_session, context: :outcome, programmes: @form.programmes,
+ patient_session, context: :patients, programmes: @form.programmes,
) %>
<% end %>
<% end %>
diff --git a/app/views/sessions/record/show.html.erb b/app/views/sessions/record/show.html.erb
index cf07ab0bc5..e72878d8be 100644
--- a/app/views/sessions/record/show.html.erb
+++ b/app/views/sessions/record/show.html.erb
@@ -50,6 +50,7 @@
+ <%= render AppSessionNeedsReviewWarningComponent.new(session: @session) %>
<%= render AppSearchResultsComponent.new(@pagy, label: "children") do %>
<% @patient_sessions.each do |patient_session| %>
<%= render AppPatientSessionSearchResultCardComponent.new(
diff --git a/app/views/sessions/register/show.html.erb b/app/views/sessions/register/show.html.erb
index d4612208a5..cf0209e970 100644
--- a/app/views/sessions/register/show.html.erb
+++ b/app/views/sessions/register/show.html.erb
@@ -15,12 +15,14 @@
@form,
url: session_register_path(@session),
programmes: @session.programmes,
+ programme_statuses: Patient::VaccinationStatus.statuses.keys,
register_statuses: @statuses,
year_groups: @session.year_groups,
) %>
+ <%= render AppSessionNeedsReviewWarningComponent.new(session: @session) %>
<%= render AppSearchResultsComponent.new(@pagy, label: "children") do %>
<% @patient_sessions.each do |patient_session| %>
<%= render AppPatientSessionSearchResultCardComponent.new(
diff --git a/app/views/sessions/triage/show.html.erb b/app/views/sessions/triage/show.html.erb
index 525596ee8b..692a7a7f64 100644
--- a/app/views/sessions/triage/show.html.erb
+++ b/app/views/sessions/triage/show.html.erb
@@ -20,6 +20,7 @@
+ <%= render AppSessionNeedsReviewWarningComponent.new(session: @session) %>
<%= render AppSearchResultsComponent.new(@pagy, label: "children") do %>
<% @patient_sessions.each do |patient_session| %>
<%= render AppPatientSessionSearchResultCardComponent.new(
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 65b11a7933..a601862799 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -131,8 +131,8 @@ en:
inclusion: Choose whether to update the child’s record with this new information
select_team_form:
attributes:
- organisation_id:
- inclusion: Choose an organisation
+ team_id:
+ inclusion: Choose a team
session_programmes_form:
attributes:
programme_ids:
@@ -519,6 +519,35 @@ en:
zero: No children
one: 1 child
other: "%{count} children"
+ children_without_nhs_number:
+ title: No NHS number
+ zero: No children without an NHS number
+ one: 1 child without an NHS number
+ other: "%{count} children without an NHS number"
+ children_with_no_consent_response:
+ title: No consent response
+ zero: No children with no response
+ one: 1 child with no response
+ other: "%{count} children with no response"
+ children_with_conflicting_consent_response:
+ title: Conflicting consent
+ zero: No children with conflicting responses
+ one: 1 child with conflicting response
+ other: "%{count} children with conflicting responses"
+ children_requiring_triage:
+ title: Triage needed
+ zero: No children requiring triage
+ one: 1 child requiring triage
+ other: "%{count} children requiring triage"
+ children_to_register:
+ title: Register attendance
+ zero: No children to register
+ one: 1 child to register
+ other: "%{count} children to register"
+ children_for_programme:
+ zero: "No children for %{programme}"
+ one: "1 child for %{programme}"
+ other: "%{count} children for %{programme}"
consent_forms:
index:
title: Unmatched consent responses
@@ -632,12 +661,12 @@ en:
index:
title: Sessions
tabs:
- overview: Overview
consent: Consent
- triage: Triage
- register: Register
+ overview: Overview
+ patients: Children
record: Record vaccinations
- outcome: Session outcomes
+ register: Register
+ triage: Triage
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 e3b9c89942..c847cc5182 100644
--- a/config/locales/status.en.yml
+++ b/config/locales/status.en.yml
@@ -2,19 +2,22 @@ en:
status:
label:
consent: Consent status
- triage: Triage status
+ programme: Programme outcome
register: Registration status
session: Session outcome
+ triage: Triage status
consent:
label:
conflicts: Conflicting consent
given: Consent given
no_response: No response
+ not_required: No consent needed
refused: Consent refused
colour:
conflicts: dark-orange
given: aqua-green
no_response: grey
+ not_required: grey
refused: red
triage:
label:
@@ -68,5 +71,5 @@ en:
vaccinated: Vaccinated
colour:
could_not_vaccinate: red
- none_yet: grey
+ none_yet: white
vaccinated: green
diff --git a/config/onboarding/coventry-training.yaml b/config/onboarding/coventry-training.yaml
index 2bd79391b9..1b5027ea6e 100644
--- a/config/onboarding/coventry-training.yaml
+++ b/config/onboarding/coventry-training.yaml
@@ -1,8 +1,11 @@
+organisation:
+ ods_code: RYG
+
team:
+ workgroup: coventrytraining
name: Coventry and Warwickshire Partnership NHS Trust
email: example@covwarkpt.nhs.uk
phone: 07700 900815
- ods_code: RYG
careplus_venue_code: CWPTSI
privacy_notice_url: https://www.covwarkpt.nhs.uk/download.cfm?ver=8286
privacy_policy_url: https://www.covwarkpt.nhs.uk/privacy
diff --git a/config/onboarding/hertfordshire-training.yaml b/config/onboarding/hertfordshire-training.yaml
index 1bafdb21ac..42eba7e534 100644
--- a/config/onboarding/hertfordshire-training.yaml
+++ b/config/onboarding/hertfordshire-training.yaml
@@ -1,8 +1,11 @@
+organisation:
+ ods_code: RY4
+
team:
+ workgroup: hertfordshiretraining
name: Hertfordshire and East Anglia Community School Age Immunisation Service
email: hct.csaisherts@nhs.net
phone: 0300 555 5055
- ods_code: RY4
careplus_venue_code: UNUSED
privacy_notice_url: https://www.hct.nhs.uk/privacy
privacy_policy_url: https://www.hct.nhs.uk/privacy
diff --git a/config/onboarding/leicestershire-training.yaml b/config/onboarding/leicestershire-training.yaml
index b4f755d892..24146e342b 100644
--- a/config/onboarding/leicestershire-training.yaml
+++ b/config/onboarding/leicestershire-training.yaml
@@ -1,8 +1,11 @@
+organisation:
+ ods_code: RT5
+
team:
+ workgroup: leicestershiretraining
name: Leicestershire Partnership Trust School Aged Immunisation Service
email: lpt.sais@nhs.net
phone: 0300 3000 007
- ods_code: RT5
careplus_venue_code: UNUSED
privacy_notice_url: https://www.leicspart.nhs.uk/privacy-policy/
privacy_policy_url: https://www.leicspart.nhs.uk/privacy-policy/
diff --git a/config/onboarding/rollover-training.yaml b/config/onboarding/rollover-training.yaml
new file mode 100644
index 0000000000..eaa80a2b5d
--- /dev/null
+++ b/config/onboarding/rollover-training.yaml
@@ -0,0 +1,40 @@
+organisation:
+ ods_code: RLVTRN
+
+team:
+ workgroup: rollovertraining
+ name: Rollover Training
+ email: rollover@example.com
+ phone: 01234 567890
+ careplus_venue_code: ROLLOVER
+ privacy_notice_url: https://www.example.com/privacy-notice
+ privacy_policy_url: https://www.example.com/privacy-policy
+
+programmes: [flu, hpv, menacwy, td_ipv]
+
+subteams:
+ generic:
+ name: Rollover Training
+ email: rollover@example.com
+ phone: 01234 567890
+
+users:
+ - email: nurse.rollover@example.com
+ password: nurse.rollover@example.com
+ given_name: Nurse
+ family_name: ROLLOVER
+
+schools:
+ generic:
+ - 115529 # primary
+ - 131806 # primary
+ - 145212 # primary
+ - 118853 # primary
+ - 145482 # secondary
+ - 137236 # secondary
+ - 136642 # secondary
+ - 135970 # secondary
+
+clinics:
+ generic:
+ - name: Rollover Training Hospital
diff --git a/config/onboarding/smoke-test.yml b/config/onboarding/smoke-test.yml
index 8c5fec7229..b7683834af 100644
--- a/config/onboarding/smoke-test.yml
+++ b/config/onboarding/smoke-test.yml
@@ -1,8 +1,10 @@
+organisation:
+ ods_code: Y90128
+
team:
name: XXX SMOKE TEST XXX
email: england.mavis@nhs.net
phone: "03003112233"
- ods_code: Y90128
careplus_venue_code: SMOKE
privacy_policy_url: https://example.com/privacy
diff --git a/config/routes.rb b/config/routes.rb
index b4530b66ad..b024df969b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -90,7 +90,7 @@
unless Rails.env.production?
namespace :testing do
resources :locations, only: :index
- resources :teams, only: :destroy, param: :ods_code
+ resources :teams, only: :destroy, param: :workgroup
post "/onboard", to: "onboard#create"
end
end
@@ -154,6 +154,7 @@
member do
get "log"
+ post "invite-to-clinic"
get "edit/nhs-number",
controller: "patients/edit",
@@ -192,6 +193,7 @@
end
resources :sessions, only: %i[edit 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"
resource :register, only: :show, controller: "sessions/register" do
@@ -203,7 +205,6 @@
as: :batch
post "batch/:programme_type/:vaccine_method", action: :update_batch
end
- resource :outcome, only: :show, controller: "sessions/outcome"
resource :invite_to_clinic,
path: "invite-to-clinic",
diff --git a/config/settings.yml b/config/settings.yml
index f2519f6227..50dce9846e 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -45,3 +45,9 @@ splunk:
enabled: true
hec_endpoint: https://firehose.inputs.splunk.aws.digital.nhs.uk/services/collector/event
hec_token: <%= Rails.application.credentials.splunk&.hec_token %>
+
+
+
+reporting_api:
+ client_app:
+ token_ttl_seconds: 600
\ No newline at end of file
diff --git a/db/migrate/20250702170322_add_one_time_tokens.rb b/db/migrate/20250702170322_add_one_time_tokens.rb
new file mode 100644
index 0000000000..bfd38e957e
--- /dev/null
+++ b/db/migrate/20250702170322_add_one_time_tokens.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class AddOneTimeTokens < ActiveRecord::Migration[8.0]
+ def change
+ create_table :reporting_api_one_time_tokens, id: false do |t|
+ t.references :user,
+ foreign_key: true,
+ null: false,
+ index: {
+ unique: true
+ }
+ t.string :token, null: false, primary_key: true
+ t.jsonb :cis2_info, null: false, default: {}
+ t.timestamps
+
+ t.index :token, unique: true
+ t.index :created_at
+ end
+ end
+end
diff --git a/db/migrate/20250725100700_rename_triage_to_triages.rb b/db/migrate/20250725100700_rename_triage_to_triages.rb
new file mode 100644
index 0000000000..0a519d0457
--- /dev/null
+++ b/db/migrate/20250725100700_rename_triage_to_triages.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class RenameTriageToTriages < ActiveRecord::Migration[8.0]
+ def up
+ rename_table :triage, :triages
+ end
+
+ def down
+ rename_table :triages, :triage
+ end
+end
diff --git a/db/migrate/20250728184911_add_workgroup_to_teams.rb b/db/migrate/20250728184911_add_workgroup_to_teams.rb
new file mode 100644
index 0000000000..3285e5e742
--- /dev/null
+++ b/db/migrate/20250728184911_add_workgroup_to_teams.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class AddWorkgroupToTeams < ActiveRecord::Migration[8.0]
+ def change
+ change_table :teams, bulk: true do |t|
+ t.string :workgroup
+ t.index :workgroup, unique: true
+ end
+
+ # We assign a random string to the workgroups to satisfy the NOT NULL
+ # constraint, but these will be changed later.
+
+ reversible do |dir|
+ dir.up do
+ Team.find_each do |team|
+ team.update_column(:workgroup, SecureRandom.uuid)
+ end
+ end
+ end
+
+ change_column_null :teams, :workgroup, false
+ end
+end
diff --git a/db/migrate/20250807070516_add_latest_session_status_to_vaccination_statuses.rb b/db/migrate/20250807070516_add_latest_session_status_to_vaccination_statuses.rb
new file mode 100644
index 0000000000..e3d1251ca7
--- /dev/null
+++ b/db/migrate/20250807070516_add_latest_session_status_to_vaccination_statuses.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class AddLatestSessionStatusToVaccinationStatuses < ActiveRecord::Migration[8.0]
+ def change
+ add_column :patient_vaccination_statuses,
+ :latest_session_status,
+ :integer,
+ null: false,
+ default: 0
+ end
+end
diff --git a/db/migrate/20250808182416_add_reporting_api_session_token_to_user.rb b/db/migrate/20250808182416_add_reporting_api_session_token_to_user.rb
new file mode 100644
index 0000000000..a2141c1598
--- /dev/null
+++ b/db/migrate/20250808182416_add_reporting_api_session_token_to_user.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class AddReportingAPISessionTokenToUser < ActiveRecord::Migration[8.0]
+ def change
+ add_column :users, :reporting_api_session_token, :string, null: true
+ add_index :users, :reporting_api_session_token, unique: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 996672496d..849f5c5c60 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_01_143843) do
+ActiveRecord::Schema[8.0].define(version: 2025_08_08_182416) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "pg_trgm"
@@ -256,8 +256,8 @@
t.boolean "notify_parents_on_vaccination"
t.datetime "submitted_at", null: false
t.integer "vaccine_methods", default: [], null: false, array: true
- t.boolean "notify_parent_on_refusal"
t.integer "academic_year", null: false
+ t.boolean "notify_parent_on_refusal"
t.index ["academic_year"], name: "index_consents_on_academic_year"
t.index ["parent_id"], name: "index_consents_on_parent_id"
t.index ["patient_id"], name: "index_consents_on_patient_id"
@@ -622,6 +622,7 @@
t.bigint "programme_id", null: false
t.integer "status", default: 0, null: false
t.integer "academic_year", null: false
+ t.integer "latest_session_status", default: 0, null: false
t.index ["patient_id", "programme_id", "academic_year"], name: "idx_on_patient_id_programme_id_academic_year_fc0b47b743", unique: true
t.index ["status"], name: "index_patient_vaccination_statuses_on_status"
end
@@ -680,6 +681,16 @@
t.index ["type"], name: "index_programmes_on_type", unique: true
end
+ create_table "reporting_api_one_time_tokens", primary_key: "token", id: :string, force: :cascade do |t|
+ t.bigint "user_id", null: false
+ t.jsonb "cis2_info", default: {}, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["created_at"], name: "index_reporting_api_one_time_tokens_on_created_at"
+ t.index ["token"], name: "index_reporting_api_one_time_tokens_on_token", unique: true
+ t.index ["user_id"], name: "index_reporting_api_one_time_tokens_on_user_id", unique: true
+ end
+
create_table "school_move_log_entries", force: :cascade do |t|
t.bigint "patient_id", null: false
t.bigint "user_id"
@@ -790,8 +801,10 @@
t.string "privacy_notice_url", null: false
t.string "phone_instructions"
t.bigint "organisation_id", null: false
+ t.string "workgroup", null: false
t.index ["name"], name: "index_teams_on_name", unique: true
t.index ["organisation_id"], name: "index_teams_on_organisation_id"
+ t.index ["workgroup"], name: "index_teams_on_workgroup", unique: true
end
create_table "teams_users", id: false, force: :cascade do |t|
@@ -801,7 +814,7 @@
t.index ["user_id", "team_id"], name: "index_teams_users_on_user_id_and_team_id"
end
- create_table "triage", force: :cascade do |t|
+ create_table "triages", force: :cascade do |t|
t.integer "status", null: false
t.text "notes", default: "", null: false
t.datetime "created_at", null: false
@@ -813,11 +826,11 @@
t.datetime "invalidated_at"
t.integer "vaccine_method"
t.integer "academic_year", null: false
- t.index ["academic_year"], name: "index_triage_on_academic_year"
- t.index ["patient_id"], name: "index_triage_on_patient_id"
- t.index ["performed_by_user_id"], name: "index_triage_on_performed_by_user_id"
- t.index ["programme_id"], name: "index_triage_on_programme_id"
- t.index ["team_id"], name: "index_triage_on_team_id"
+ t.index ["academic_year"], name: "index_triages_on_academic_year"
+ t.index ["patient_id"], name: "index_triages_on_patient_id"
+ t.index ["performed_by_user_id"], name: "index_triages_on_performed_by_user_id"
+ t.index ["programme_id"], name: "index_triages_on_programme_id"
+ t.index ["team_id"], name: "index_triages_on_team_id"
end
create_table "users", force: :cascade do |t|
@@ -837,8 +850,10 @@
t.string "family_name", null: false
t.string "session_token"
t.integer "fallback_role", default: 0, null: false
+ t.string "reporting_api_session_token"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", unique: true
+ t.index ["reporting_api_session_token"], name: "index_users_on_reporting_api_session_token", unique: true
end
create_table "vaccination_records", force: :cascade do |t|
@@ -866,8 +881,8 @@
t.bigint "vaccine_id"
t.boolean "full_dose"
t.datetime "nhs_immunisations_api_synced_at"
- t.string "nhs_immunisations_api_id"
t.string "nhs_immunisations_api_etag"
+ t.string "nhs_immunisations_api_id"
t.integer "protocol"
t.datetime "nhs_immunisations_api_sync_pending_at"
t.boolean "notify_parents"
@@ -993,6 +1008,7 @@
add_foreign_key "pre_screenings", "patient_sessions"
add_foreign_key "pre_screenings", "programmes"
add_foreign_key "pre_screenings", "users", column: "performed_by_user_id"
+ add_foreign_key "reporting_api_one_time_tokens", "users"
add_foreign_key "school_move_log_entries", "locations", column: "school_id"
add_foreign_key "school_move_log_entries", "patients"
add_foreign_key "school_move_log_entries", "users"
@@ -1012,10 +1028,10 @@
add_foreign_key "team_programmes", "programmes"
add_foreign_key "team_programmes", "teams"
add_foreign_key "teams", "organisations"
- add_foreign_key "triage", "patients"
- add_foreign_key "triage", "programmes"
- add_foreign_key "triage", "teams"
- add_foreign_key "triage", "users", column: "performed_by_user_id"
+ add_foreign_key "triages", "patients"
+ add_foreign_key "triages", "programmes"
+ add_foreign_key "triages", "teams"
+ add_foreign_key "triages", "users", column: "performed_by_user_id"
add_foreign_key "vaccination_records", "batches"
add_foreign_key "vaccination_records", "patients"
add_foreign_key "vaccination_records", "programmes"
diff --git a/db/seeds.rb b/db/seeds.rb
index 1fda2ecd73..8eea7c5c2a 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -20,13 +20,15 @@ def create_gp_practices
end
def create_team(ods_code:)
- # TODO: Select the right team based on an identifier.
- Team.joins(:organisation).find_by(organisation: { ods_code: }) ||
+ workgroup = ods_code.downcase
+
+ Team.find_by(workgroup:) ||
FactoryBot.create(
:team,
:with_generic_clinic,
ods_code:,
- programmes: Programme.all
+ programmes: Programme.all,
+ workgroup:
)
end
diff --git a/docs/diagrams/reporting_auth/high-level-auth-flow-with-mavis-and-CIS2.puml b/docs/diagrams/reporting_auth/high-level-auth-flow-with-mavis-and-CIS2.puml
new file mode 100644
index 0000000000..547d985e69
--- /dev/null
+++ b/docs/diagrams/reporting_auth/high-level-auth-flow-with-mavis-and-CIS2.puml
@@ -0,0 +1,37 @@
+@startuml
+title "High-level Authentication Flow with CIS2 (OIDC)"
+|User|
+|CIS2|
+|Mavis|
+|Mavis Reporting|
+|User|
+start
+:click 'Start' button;
+|Mavis|
+:redirect to CIS2;
+|CIS2|
+:respond with login form;
+|User|
+:provide credentials;
+->credentials;
+|CIS2|
+:verify credentials;
+:create user session;
+:generate authorization code;
+->redirect back with authorization code;
+|User|
+:request redirect URI;
+|Mavis|
+:verify authorization code;
+->client_id & authorization code;
+|CIS2|
+:respond with access token;
+|Mavis|
+:use access token to get user info;
+|CIS2|
+->user info;
+|Mavis|
+:store user info in session;
+:store access token against user in DB;
+stop
+@enduml
\ No newline at end of file
diff --git a/docs/diagrams/reporting_auth/high-level-auth-flow-with-reporting-app.puml b/docs/diagrams/reporting_auth/high-level-auth-flow-with-reporting-app.puml
new file mode 100644
index 0000000000..de1eec09fd
--- /dev/null
+++ b/docs/diagrams/reporting_auth/high-level-auth-flow-with-reporting-app.puml
@@ -0,0 +1,67 @@
+@startuml
+title "High-level Authentication Flow with Reporting Component (OAuth 2.0)"
+|User|
+|CIS2|
+|Mavis|
+|Mavis Reporting|
+|User|
+start
+:Visit reporting app;
+|Mavis Reporting|
+:redirect to Mavis;
+|Mavis|
+:redirect to CIS2;
+|CIS2|
+:respond with login form;
+|User|
+:provide credentials;
+->credentials;
+|CIS2|
+:verify credentials;
+:create user session;
+:generate authorization code;
+->redirect back with authorization code;
+|User|
+:request redirect URI;
+->authorization code;
+|Mavis|
+:verify authorization code;
+->client_id & authorization code;
+|CIS2|
+:verify authorization code;
+:respond with access token;
+->access token;
+|Mavis|
+:create user session;
+:store access token against user in DB;
+:generate reporting app authorization code for user;
+:redirect back to reporting app with authorization code;
+|User|
+:request redirect_uri;
+->authorization code;
+|Mavis Reporting|
+:verify authorization code;
+->client_id & authorization code;
+|Mavis|
+:verify authorization code;
+:generate JWT with user & role info & reporting_api_session_token;
+:sign JWT with client_secret;
+:store JWT against user in DB;
+:respond with JWT;
+->JWT;
+|Mavis Reporting|
+:decode JWT & verify signature;
+:store user & role info & reporting_api_session_token in session;
+:use JWT to authenticate Mavis API requests;
+->API request, Authorization: "Bearer "";
+|Mavis|
+:decode JWT & verify signature;
+:verify reporting_api_session_token is valid for user;
+:use user to restrict queries;
+:return data as JSON;
+|Mavis Reporting|
+:render HTML;
+->respond with HTML, status 200;
+|User|
+stop
+@enduml
\ No newline at end of file
diff --git a/docs/diagrams/reporting_auth/user-logs-in-to-mavis-with-local-pwd.puml b/docs/diagrams/reporting_auth/user-logs-in-to-mavis-with-local-pwd.puml
new file mode 100644
index 0000000000..bb37d85f44
--- /dev/null
+++ b/docs/diagrams/reporting_auth/user-logs-in-to-mavis-with-local-pwd.puml
@@ -0,0 +1,33 @@
+@startuml
+title "User logs in to Mavis with email & password"
+|User|
+|CIS2|
+|Mavis|
+|Mavis Reporting|
+|User|
+start
+:click 'Start' button;
+|Mavis|
+if (CIS2 login enabled?) is (N) then
+ repeat
+ :respond with login form;
+ |User|
+ :provide credentials;
+ |Mavis|
+ repeat while (valid credentials?) is (N) not (Y)
+ :respond with choose role page;
+ |User|
+ :choose role;
+ |Mavis|
+ :store user & role info in session as 'cis2_info';
+ :store reporting_api_session_token in DB;
+ :respond with cookie and redirect;
+ |User|
+ :access redirected URL;
+ stop
+else
+ |Mavis|
+ :(see other process);
+ stop
+end
+@enduml
\ No newline at end of file
diff --git a/docs/diagrams/reporting_auth/user-visits-the-reporting-app.puml b/docs/diagrams/reporting_auth/user-visits-the-reporting-app.puml
new file mode 100644
index 0000000000..7866a6d58f
--- /dev/null
+++ b/docs/diagrams/reporting_auth/user-visits-the-reporting-app.puml
@@ -0,0 +1,49 @@
+@startuml
+title "User visits the Reporting app"
+|User|
+|CIS2|
+|Mavis|
+|Mavis Reporting|
+|User|
+start
+->GET /reporting/...;
+|Mavis Reporting|
+if (has JWT in cookie?) is (Y) then
+ :API call with JWT as \n Authorization header;
+ |Mavis|
+ if (JWT is valid?) is (Y) then
+ :get the user with the JWT's session token;
+ if (user with that session token exists?) then
+ :use that user to filter returned data;
+ :respond with 200 & data;
+ |Mavis Reporting|
+ else (N)
+ |Mavis|
+ :respond with 401;
+ |Mavis Reporting|
+ endif
+ else (N)
+ |Mavis|
+ :respond with 401;
+ |Mavis Reporting|
+ endif
+ if (response status is 200?) is (Y) then
+ :render html;
+ :respond with 200 & html;
+ else (N)
+ |Mavis Reporting|
+ :clear user session;
+ :redirect to Mavis start \nwith redirect_uri param;
+ endif
+else (N)
+ |Mavis Reporting|
+ :redirect to Mavis start \nwith redirect_uri param;
+ |User|
+endif
+|Mavis Reporting|
+->response;
+|User|
+:process response;
+stop
+
+@enduml
\ No newline at end of file
diff --git a/docs/managing-teams.md b/docs/managing-teams.md
index 9b71611903..ae07df45a3 100644
--- a/docs/managing-teams.md
+++ b/docs/managing-teams.md
@@ -46,12 +46,12 @@ clinics:
[config-onboarding]: /config/onboarding
-### Rake task
+### Command
-Once the file has been written you can use the `onboard` Rake task to set everything up in the service.
+Once the file has been written you can use the `onboard` command to set everything up in the service.
```sh
-$ bundle exec rails onboard[path/to/configuration.yaml]
+$ bin/mavis teams onboard path/to/configuration.yaml
```
If any validation errors are detected in the file they will be output and nothing will be processed, only if the file is completely valid will anything be processed.
diff --git a/docs/reporting-component-authentication.adoc b/docs/reporting-component-authentication.adoc
new file mode 100644
index 0000000000..c98cc2d82d
--- /dev/null
+++ b/docs/reporting-component-authentication.adoc
@@ -0,0 +1,47 @@
+
+:imagesdir: images
+:source-highlighter: pygments
+
+ifdef::env-github[]
+// If on GitHub, define attributes so we can find our diagram files and render
+// them.
+
+// The branch will be used to find the correct diagrams to render below.
+// When PRing changes to the diagrams you can change this attributes
+// temporarily to the name of the branch you're working on. But don't forget
+// to change it back to main before merging!!
+:github-branch: main
+
+:github-repo: nhsuk/manage-vaccinations-in-schools
+
+// URL for PlantUML Proxy. Using an attribute mainly because it's just tidier.
+:plantuml-proxy-url: http://www.plantuml.com/plantuml/proxy?cache=no&src=
+
+// Full path prefix we'll use for diagrams below.
+:diagram-path-url: {plantuml-proxy-url}https://raw.githubusercontent.com/{github-repo}/{github-branch}/docs
+endif::[]
+
+
+= Reporting Component Authentication
+
+The main Mavis application uses CIS2 to authenticate users, exchanging authorization codes & obtaining an access token for the user session using OpenID Connect (OIDC) - see the link:https://digital.nhs.uk/services/care-identity-service/applications-and-services/cis2-authentication/guidance-for-developers/openid-connect-overview[CIS2 documentation] for details.
+
+At a high-level, that flow works like this - (implementing link:../adr/00012-auth-pattern-for-commissioner-reporting-app.md[ADR00012]):
+
+ifdef::env-github[]
+image::{diagram-path-url}/diagrams/reporting_auth/high-level-auth-flow-with-mavis-and-CIS2.puml[Mavis/CIS2 authentication flow diagram]
+endif::[]
+
+Extending this process to include the reporting component is done in a very similar way, using link:https://datatracker.ietf.org/doc/html/rfc6749#section-4.1[OAuth 2.0 Authorization Code Grant] (upon which OIDC is based)
+
+ifdef::env-github[]
+image::{diagram-path-url}/diagrams/reporting_auth/high-level-auth-flow-with-reporting-app.puml[Mavis Reporting/Mavis/CIS2 authentication diagram]
+endif::[]
+
+
+The reporting app can then use the JWT to authenticate future API requests to Mavis as follows:
+
+
+ifdef::env-github[]
+image::{diagram-path-url}/diagrams/reporting_auth/user-visits-the-reporting-app.puml[User visiting the reporting app diagram]
+endif::[]
\ No newline at end of file
diff --git a/lib/core_ext/integer/year_group.rb b/lib/core_ext/integer/year_group.rb
index 1fba228a62..cb35e2ccde 100644
--- a/lib/core_ext/integer/year_group.rb
+++ b/lib/core_ext/integer/year_group.rb
@@ -1,11 +1,11 @@
# frozen_string_literal: true
class Integer
- def to_year_group(academic_year:)
- # Children normally start school the September after their 4th birthday.
- # https://www.gov.uk/schools-admissions/school-starting-age
+ AGE_CHILDREN_START_SCHOOL = 5
- (academic_year || Date.current.academic_year) - self - 5
+ def to_year_group(academic_year: nil)
+ (academic_year || Date.current.academic_year) - self -
+ AGE_CHILDREN_START_SCHOOL
end
alias_method :to_birth_academic_year, :to_year_group
diff --git a/lib/generate/cohort_imports.rb b/lib/generate/cohort_imports.rb
index 4fd8bf6f57..d3438bb569 100644
--- a/lib/generate/cohort_imports.rb
+++ b/lib/generate/cohort_imports.rb
@@ -4,197 +4,166 @@
Faker::Config.locale = "en-GB"
-# Use this to generate a cohort import CSV file for performance testing.
-#
-# Usage from the Rails console:
-#
-# Create a cohort import of 1000 children for all the school sessions for the
-# org A9A5A in the local db:
-#
-# Generate::CohortImports.call(patient_count: 1000)
-#
-# You can also generate a cohort import for sessions not in the local db.
-#
-# Generate::CohortImports.call(
-# patient_count: 1000,
-# urns: ["123456", "987654"],
-# school_year_groups: {
-# "123456" => [-2, -1, 0, 1, 2, 3, 4, 5, 6],
-# "987654" => [9, 10, 11, 12, 13]
-# }
-# )
-#
-# You can pull out the year groups with the following:
-#
-# org = Organisation.find_by(ods_code: "A9A5A")
-# team = org.teams.find_by(name: "")
-# team.locations.school.pluck(:urn, :year_groups) .to_h
-#
-module Generate
- class CohortImports
- attr_reader :ods_code,
- :team,
- :programme,
- :urns,
- :patient_count,
- :school_year_groups,
- :progress_bar
-
- def initialize(
- ods_code: "A9A5A",
- programme: "hpv",
- urns: nil,
- school_year_groups: nil,
- patient_count: 10,
- progress_bar: nil
- )
- # TODO: Select the right team based on an identifier.
- @team = Team.joins(:organisation).find_by(organisation: { ods_code: })
- @programme = Programme.find_by(type: programme)
- @urns =
- urns || @team.locations.select { it.urn.present? }.sample(3).pluck(:urn)
- @school_year_groups = school_year_groups
- @patient_count = patient_count
- @progress_bar = progress_bar
- @nhs_numbers = Set.new
- end
+class Generate::CohortImports
+ def initialize(
+ team:,
+ programmes: nil,
+ urns: nil,
+ school_year_groups: nil,
+ patient_count: 10,
+ progress_bar: nil
+ )
+ @team = team
+ @programmes = programmes.presence || team.programmes
+ @urns = urns || @team.schools.pluck(:urn)
+ @school_year_groups = school_year_groups
+ @patient_count = patient_count
+ @progress_bar = progress_bar
+ @nhs_numbers = Set.new
+ end
- def self.call(...) = new(...).call
+ def self.call(...) = new(...).call
- def call
- write_cohort_import_csv
- end
+ def call = write_cohort_import_csv
- def patients
- patient_count.times.lazy.map { build_patient }
- end
+ def patients
+ patient_count.times.lazy.map { build_patient }
+ end
+
+ private
- private
+ attr_reader :team,
+ :programmes,
+ :urns,
+ :patient_count,
+ :school_year_groups,
+ :progress_bar
- delegate :organisation, to: :team
+ def academic_year = AcademicYear.current
- def cohort_import_csv_filepath
- timestamp = Time.current.strftime("%Y%m%d%H%M%S")
- size =
- ActiveSupport::NumberHelper.number_to_human(
- @patient_count,
- units: {
- thousand: "k",
- million: "m"
- },
- format: "%n%u"
+ def all_year_groups
+ programmes.flat_map(&:default_year_groups).uniq
+ end
+
+ def cohort_import_csv_filepath
+ @cohort_import_csv_filepath ||=
+ begin
+ timestamp = Time.current.strftime("%Y%m%d%H%M%S")
+ size =
+ ActiveSupport::NumberHelper.number_to_human(
+ @patient_count,
+ units: {
+ thousand: "k",
+ million: "m"
+ },
+ format: "%n%u"
+ )
+ Rails.root.join(
+ "tmp/cohort-import-" \
+ "#{team.workgroup}-#{programmes.map(&:type).join("-")}-#{size}-#{timestamp}.csv"
)
- Rails.root.join(
- "tmp/cohort-import-" \
- "#{organisation.ods_code}-#{programme.type}-#{size}-#{timestamp}.csv"
- )
- end
+ end
+ end
- def write_cohort_import_csv
- CSV.open(cohort_import_csv_filepath, "w") do |csv|
- csv << %w[
- CHILD_ADDRESS_LINE_1
- CHILD_ADDRESS_LINE_2
- CHILD_POSTCODE
- CHILD_TOWN
- CHILD_PREFERRED_GIVEN_NAME
- CHILD_DATE_OF_BIRTH
- CHILD_FIRST_NAME
- CHILD_LAST_NAME
- CHILD_NHS_NUMBER
- PARENT_1_EMAIL
- PARENT_1_NAME
- PARENT_1_PHONE
- PARENT_1_RELATIONSHIP
- PARENT_2_EMAIL
- PARENT_2_NAME
- PARENT_2_PHONE
- PARENT_2_RELATIONSHIP
- CHILD_SCHOOL_URN
+ def write_cohort_import_csv
+ CSV.open(cohort_import_csv_filepath, "w") do |csv|
+ csv << %w[
+ CHILD_ADDRESS_LINE_1
+ CHILD_ADDRESS_LINE_2
+ CHILD_POSTCODE
+ CHILD_TOWN
+ CHILD_PREFERRED_GIVEN_NAME
+ CHILD_DATE_OF_BIRTH
+ CHILD_FIRST_NAME
+ CHILD_LAST_NAME
+ CHILD_NHS_NUMBER
+ PARENT_1_EMAIL
+ PARENT_1_NAME
+ PARENT_1_PHONE
+ PARENT_1_RELATIONSHIP
+ PARENT_2_EMAIL
+ PARENT_2_NAME
+ PARENT_2_PHONE
+ PARENT_2_RELATIONSHIP
+ CHILD_SCHOOL_URN
+ ]
+
+ patients.each do |patient|
+ csv << [
+ patient.address_line_1,
+ patient.address_line_2,
+ patient.address_postcode,
+ patient.address_town,
+ patient.preferred_given_name,
+ patient.date_of_birth,
+ patient.given_name,
+ patient.family_name,
+ patient.nhs_number,
+ patient.parents.first&.email,
+ patient.parents.first&.full_name,
+ patient.parents.first&.phone,
+ patient.parent_relationships.first&.type,
+ patient.parents.second&.email,
+ patient.parents.second&.full_name,
+ patient.parents.second&.phone,
+ patient.parent_relationships.second&.type,
+ patient.school.urn
]
-
- patients.each do |patient|
- csv << [
- patient.address_line_1,
- patient.address_line_2,
- patient.address_postcode,
- patient.address_town,
- patient.preferred_given_name,
- patient.date_of_birth,
- patient.given_name,
- patient.family_name,
- patient.nhs_number,
- patient.parents.first&.email,
- patient.parents.first&.full_name,
- patient.parents.first&.phone,
- patient.parent_relationships.first&.type,
- patient.parents.second&.email,
- patient.parents.second&.full_name,
- patient.parents.second&.phone,
- patient.parent_relationships.second&.type,
- patient.school.urn
- ]
- progress_bar&.increment
- end
+ progress_bar&.increment
end
- cohort_import_csv_filepath.to_s
end
+ cohort_import_csv_filepath.to_s
+ end
- def schools_with_year_groups
- @schools_with_year_groups ||=
- begin
- locations =
- if school_year_groups.present?
- urns.map do |urn|
- Location.new(urn:, year_groups: school_year_groups[urn])
- end
- else
- team.locations.where(urn: urns).includes(:team, :sessions)
+ def schools_with_year_groups
+ @schools_with_year_groups ||=
+ begin
+ locations =
+ if school_year_groups.present?
+ urns.map do |urn|
+ Location.new(urn:, year_groups: school_year_groups[urn])
end
- locations.select do
- (it.year_groups & programme.default_year_groups).any?
+ else
+ team.locations.where(urn: urns).includes(:team, :sessions)
end
- end
- end
- def build_patient
- school = schools_with_year_groups.sample
- year_group ||= (school.year_groups & programme.default_year_groups).sample
- nhs_number = nil
- loop do
- nhs_number = Faker::NationalHealthService.british_number.gsub(" ", "")
- break unless nhs_number.in? @nhs_numbers
+ locations.select { (it.year_groups & all_year_groups).any? }
end
- @nhs_numbers << nhs_number
-
- FactoryBot
- .build(
- :patient,
- school:,
- team:,
- date_of_birth: date_of_birth_for_year(year_group),
- nhs_number:
- )
- .tap do |patient|
- patient.parents =
- FactoryBot.build_list(:parent, 2, family_name: patient.family_name)
- patient.parent_relationships =
- patient.parents.map do
- FactoryBot.build(:parent_relationship, parent: it, patient:)
- end
- end
- end
+ end
- def date_of_birth_for_year(year_group, academic_year: AcademicYear.pending)
- if year_group < 12
- rand(
- year_group.to_birth_academic_year(
- academic_year:
- ).to_academic_year_date_range
- )
- else
- raise "Unknown year group: #{year_group}"
- end
+ def build_patient
+ school = schools_with_year_groups.sample
+ year_group ||= (school.year_groups & all_year_groups).sample
+ nhs_number = nil
+ loop do
+ nhs_number = Faker::NationalHealthService.british_number.gsub(" ", "")
+ break unless nhs_number.in? @nhs_numbers
end
+ @nhs_numbers << nhs_number
+
+ FactoryBot
+ .build(
+ :patient,
+ school:,
+ team:,
+ date_of_birth: date_of_birth_for_year(year_group),
+ nhs_number:
+ )
+ .tap do |patient|
+ patient.parents =
+ FactoryBot.build_list(:parent, 2, family_name: patient.family_name)
+ patient.parent_relationships =
+ patient.parents.map do
+ FactoryBot.build(:parent_relationship, parent: it, patient:)
+ end
+ end
+ end
+
+ def date_of_birth_for_year(year_group)
+ rand(
+ year_group.to_birth_academic_year(
+ academic_year:
+ ).to_academic_year_date_range
+ )
end
end
diff --git a/lib/generate/consents.rb b/lib/generate/consents.rb
index 037392b478..2d7d924c20 100644
--- a/lib/generate/consents.rb
+++ b/lib/generate/consents.rb
@@ -1,131 +1,129 @@
# frozen_string_literal: true
-module Generate
- class Consents
- attr_reader :team, :programme
-
- def initialize(
- team:,
- programme: nil,
- session: nil,
- refused: 0,
- given: 0,
- given_needs_triage: 0
- )
- validate_programme_and_session(programme, session) if programme
-
- @team = team
- @programme = programme || team.programmes.sample
- @session = session
- @refused = refused
- @given = given
- @given_needs_triage = given_needs_triage
- @updated_patients = []
- @updated_sessions = Set.new
- end
+class Generate::Consents
+ def initialize(
+ team:,
+ programme: nil,
+ session: nil,
+ refused: 0,
+ given: 0,
+ given_needs_triage: 0
+ )
+ validate_programme_and_session(programme, session) if programme
+
+ @team = team
+ @programme = programme || team.programmes.sample
+ @session = session
+ @refused = refused
+ @given = given
+ @given_needs_triage = given_needs_triage
+ @updated_patients = []
+ @updated_sessions = Set.new
+ end
- def call
- create_consents(:refused, @refused)
- create_consents(:given, @given)
- create_consents(:needing_triage, @given_needs_triage)
+ def call
+ create_consents(:refused, @refused)
+ create_consents(:given, @given)
+ create_consents(:needing_triage, @given_needs_triage)
- StatusUpdater.call(patient: @updated_patients, session: @updated_sessions)
- end
+ StatusUpdater.call(patient: @updated_patients, session: @updated_sessions)
+ end
- def self.call(...) = new(...).call
-
- private
-
- def patients
- @patients ||=
- begin
- sessions =
- if @session
- [@session]
- else
- team
- .sessions
- .eager_load(:location)
- .merge(Location.school)
- .has_programmes([programme])
- end
-
- sessions.flat_map do |session|
- session
- .patients
- .includes(:parents, :school, :consents, consents: :parent)
- .select { it.consents.empty? && it.parents.any? }
- end
- end
- end
+ def self.call(...) = new(...).call
- def random_patients(count)
- @patients_randomised ||= patients.shuffle
- @patients_randomised
- .shift(count)
- .tap do
- if it.size < count
- raise "Only #{it.size} patients without consent in #{programme.type} programme"
- end
- end
- end
+ private
- def session_for(patient)
- @session ||
- patient
- .sessions
- .eager_load(:location)
- .merge(Location.school)
- .has_programmes([programme])
- .sample
- end
+ attr_reader :team, :programme
- def create_consents(response, count)
- available_patient_sessions =
- random_patients(count).map { [it, session_for(it)] }
+ def patients
+ @patients ||=
+ begin
+ sessions =
+ if @session
+ [@session]
+ else
+ team
+ .sessions
+ .eager_load(:location)
+ .merge(Location.school)
+ .has_programmes([programme])
+ end
- if response == :needing_triage
- response = :given
- traits = %i[given needing_triage]
- else
- traits = [response]
+ sessions.flat_map do |session|
+ session
+ .patients
+ .includes(:parents, :school, :consents, consents: :parent)
+ .select { it.consents.empty? && it.parents.any? }
+ end
end
+ end
- consents =
- available_patient_sessions.map do |patient, session|
- school = session.location.school? ? session.location : patient.school
-
- @updated_patients << patient
- @updated_sessions << session
-
- FactoryBot.build(
- :consent,
- *traits,
- patient:,
- programme:,
- team:,
- consent_form:
- FactoryBot.build(
- :consent_form,
- team:,
- programmes: [programme],
- session:,
- school:,
- response:
- )
- )
+ def random_patients(count)
+ @patients_randomised ||= patients.shuffle
+ @patients_randomised
+ .shift(count)
+ .tap do
+ if it.size < count
+ raise "Only #{it.size} patients without consent in #{programme.type} programme"
end
- Consent.import!(consents, recursive: true)
+ end
+ end
+
+ def session_for(patient)
+ @session ||
+ patient
+ .sessions
+ .eager_load(:location)
+ .merge(Location.school)
+ .has_programmes([programme])
+ .sample
+ end
+
+ def create_consents(response, count)
+ available_patient_sessions =
+ random_patients(count).map { [it, session_for(it)] }
+
+ if response == :needing_triage
+ response = :given
+ traits = %i[given needing_triage]
+ else
+ traits = [response]
end
- def validate_programme_and_session(programme, session)
- if session
- if session.programmes.exclude?(programme)
- raise "Session does not support programme #{programme.type}"
- end
- elsif programme.sessions.none? { it.location.school? }
- raise "Programme #{programme.type} does not have a school session"
+ consents =
+ available_patient_sessions.map do |patient, session|
+ school = session.location.school? ? session.location : patient.school
+
+ @updated_patients << patient
+ @updated_sessions << session
+
+ FactoryBot.build(
+ :consent,
+ *traits,
+ patient:,
+ programme:,
+ team:,
+ consent_form:
+ FactoryBot.build(
+ :consent_form,
+ team:,
+ programmes: [programme],
+ session:,
+ school:,
+ response:
+ )
+ )
+ end
+ Consent.import!(consents, recursive: true)
+ end
+
+ def validate_programme_and_session(programme, session)
+ if session
+ if session.programmes.exclude?(programme)
+ raise "Session does not support programme #{programme.type}"
end
+ elsif programme.sessions.none? { it.location.school? }
+ raise "Programme #{programme.type} does not have a school session"
end
end
end
diff --git a/lib/generate/triages.rb b/lib/generate/triages.rb
index c3b44ad0ef..f06c9c69fb 100644
--- a/lib/generate/triages.rb
+++ b/lib/generate/triages.rb
@@ -1,68 +1,66 @@
# frozen_string_literal: true
-module Generate
- class Triages
- attr_reader :config, :team, :programme
+class Generate::Triages
+ def initialize(
+ team:,
+ programme: nil,
+ session: nil,
+ ready_to_vaccinate: 1,
+ do_not_vaccinate: 1
+ )
+ @team = team
+ @programme = programme || team.programmes.sample
+ @session = session
+ @ready_to_vaccinate = ready_to_vaccinate
+ @do_not_vaccinate = do_not_vaccinate
+ end
- def initialize(
- team:,
- programme: nil,
- session: nil,
- ready_to_vaccinate: 1,
- do_not_vaccinate: 1
- )
- @team = team
- @programme = programme || team.programmes.sample
- @session = session
- @ready_to_vaccinate = ready_to_vaccinate
- @do_not_vaccinate = do_not_vaccinate
- end
+ def call
+ create_triage_with_status(:ready_to_vaccinate, @ready_to_vaccinate)
+ create_triage_with_status(:do_not_vaccinate, @do_not_vaccinate)
+ end
- def call
- create_triage_with_status(:ready_to_vaccinate, @ready_to_vaccinate)
- create_triage_with_status(:do_not_vaccinate, @do_not_vaccinate)
- end
+ def self.call(...) = new(...).call
- def self.call(...) = new(...).call
+ private
- private
+ attr_reader :team, :programme
- def academic_year = Date.current.academic_year
+ def academic_year = Date.current.academic_year
- def patients
- (@session.presence || team)
- .patients
- .includes(:triage_statuses)
- .appear_in_programmes([programme], academic_year:)
- .select { it.triage_status(programme:, academic_year:).required? }
- end
+ def patients
+ (@session.presence || team)
+ .patients
+ .includes(:triage_statuses)
+ .appear_in_programmes([programme], academic_year:)
+ .select { it.triage_status(programme:, academic_year:).required? }
+ end
- def random_patients(count)
- patients
- .shuffle
- .take(count)
- .tap do
- raise "Not enough patients to generate triages" if it.size < count
- end
- end
+ def random_patients(count)
+ patients
+ .shuffle
+ .take(count)
+ .tap do
+ raise "Not enough patients to generate triages" if it.size < count
+ end
+ end
- def user
- @user ||= team.users.includes(:teams).sample
- end
+ def user
+ @user ||= team.users.includes(:teams).sample
+ end
- def create_triage_with_status(status, count)
- available_patients = random_patients(count)
+ def create_triage_with_status(status, count)
+ available_patients = random_patients(count)
- available_patients.each do |patient|
- FactoryBot.create(
- :triage,
- status,
- patient:,
- programme:,
- performed_by: user,
- team:
- )
- end
+ available_patients.each do |patient|
+ FactoryBot.create(
+ :triage,
+ status,
+ patient:,
+ programme:,
+ performed_by: user,
+ team:
+ )
end
end
end
diff --git a/lib/generate/vaccination_records.rb b/lib/generate/vaccination_records.rb
index cd2cf1a44f..da86227814 100644
--- a/lib/generate/vaccination_records.rb
+++ b/lib/generate/vaccination_records.rb
@@ -1,112 +1,110 @@
# frozen_string_literal: true
-module Generate
- class VaccinationRecords
- attr_reader :config, :team, :programme, :session, :administered
-
- def initialize(team:, programme: nil, session: nil, administered: nil)
- @team = team
- @programme = programme || team.programmes.includes(:teams).sample
- @session = session
- @administered = administered
- end
-
- def call
- create_vaccinations
- end
+class Generate::VaccinationRecords
+ def initialize(team:, programme: nil, session: nil, administered: nil)
+ @team = team
+ @programme = programme || team.programmes.includes(:teams).sample
+ @session = session
+ @administered = administered
+ end
- def self.call(...) = new(...).call
+ def call
+ create_vaccinations
+ end
- private
+ def self.call(...) = new(...).call
- def create_vaccinations
- session_attendances = []
- vaccination_records = []
+ private
- random_patient_sessions.each do |patient_session|
- patient_session_id = patient_session.id
- session_date_ids = patient_session.session.session_dates.pluck(:id)
+ attr_reader :config, :team, :programme, :session, :administered
- unless SessionAttendance.exists?(
- patient_session_id:,
- session_date_id: session_date_ids
- )
- session_attendances << FactoryBot.build(
- :session_attendance,
- :present,
- patient_session:
- )
- end
+ def create_vaccinations
+ session_attendances = []
+ vaccination_records = []
- location_name =
- patient_session.location.name if patient_session.session.clinic?
+ random_patient_sessions.each do |patient_session|
+ patient_session_id = patient_session.id
+ session_date_ids = patient_session.session.session_dates.pluck(:id)
- vaccination_records << FactoryBot.build(
- :vaccination_record,
- :administered,
- patient: patient_session.patient,
- programme:,
- team:,
- performed_by:,
- session: patient_session.session,
- vaccine:,
- batch:,
- location_name:
+ unless SessionAttendance.exists?(
+ patient_session_id:,
+ session_date_id: session_date_ids
+ )
+ session_attendances << FactoryBot.build(
+ :session_attendance,
+ :present,
+ patient_session:
)
end
- SessionAttendance.import!(session_attendances)
- VaccinationRecord.import!(vaccination_records)
-
- StatusUpdater.call(patient: vaccination_records.map(&:patient))
+ location_name =
+ patient_session.location.name if patient_session.session.clinic?
+
+ vaccination_records << FactoryBot.build(
+ :vaccination_record,
+ :administered,
+ patient: patient_session.patient,
+ programme:,
+ team:,
+ performed_by:,
+ session: patient_session.session,
+ vaccine:,
+ batch:,
+ location_name:
+ )
end
- def random_patient_sessions
- if administered&.positive?
- patient_sessions
- .sample(administered)
- .tap do |selected|
- if selected.size < administered
- info =
- "#{selected.size} (patient_sessions) < #{administered} (administered)"
- raise "Not enough patients to generate vaccinations: #{info}"
- end
+ SessionAttendance.import!(session_attendances)
+ VaccinationRecord.import!(vaccination_records)
+
+ StatusUpdater.call(patient: vaccination_records.map(&:patient))
+ end
+
+ def random_patient_sessions
+ if administered&.positive?
+ patient_sessions
+ .sample(administered)
+ .tap do |selected|
+ if selected.size < administered
+ info =
+ "#{selected.size} (patient_sessions) < #{administered} (administered)"
+ raise "Not enough patients to generate vaccinations: #{info}"
end
- else
- patient_sessions
- end
+ end
+ else
+ patient_sessions
end
+ end
- def patient_sessions
- (session.presence || team)
- .patient_sessions
- .joins(:patient)
- .includes(
- :session,
- :location,
- session: :session_dates,
- patient: %i[consent_statuses vaccination_statuses triage_statuses]
+ def patient_sessions
+ (session.presence || team)
+ .patient_sessions
+ .joins(:patient)
+ .includes(
+ :session,
+ :location,
+ session: :session_dates,
+ patient: %i[consent_statuses vaccination_statuses triage_statuses]
+ )
+ .appear_in_programmes([programme])
+ .has_consent_status("given", programme:)
+ .select do
+ it.patient.consent_given_and_safe_to_vaccinate?(
+ programme:,
+ academic_year: it.session.academic_year
)
- .appear_in_programmes([programme])
- .has_consent_status("given", programme:)
- .select do
- it.patient.consent_given_and_safe_to_vaccinate?(
- programme:,
- academic_year: it.session.academic_year
- )
- end
- end
+ end
+ end
- def vaccine
- (@vaccines ||= programme.vaccines.includes(:batches).active).first
- end
+ def vaccine
+ (@vaccines ||= programme.vaccines.includes(:batches).active).first
+ end
- def batch
- (@batches ||= vaccine.batches).sample
- end
+ def batch
+ (@batches ||= vaccine.batches).sample
+ end
- def performed_by
- (@team_users ||= team.users.includes(:teams)).sample
- end
+ def performed_by
+ (@team_users ||= team.users.includes(:teams)).sample
end
end
diff --git a/lib/tasks/archive_deceased_patients.rake b/lib/tasks/archive_deceased_patients.rake
deleted file mode 100644
index 5623ef920a..0000000000
--- a/lib/tasks/archive_deceased_patients.rake
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-desc "Migrate patients who are deceased to ensure they're archived."
-task archive_deceased_patients: :environment do
- Patient
- .deceased
- .includes(:teams)
- .find_each do |patient|
- # We're using a private method here in this temporary task since we will
- # delete this task once it's been run in production.
- patient.send(:archive_due_to_deceased!)
- end
-end
diff --git a/lib/tasks/archive_moved_out_of_cohort_patients.rake b/lib/tasks/archive_moved_out_of_cohort_patients.rake
deleted file mode 100644
index 094f968539..0000000000
--- a/lib/tasks/archive_moved_out_of_cohort_patients.rake
+++ /dev/null
@@ -1,33 +0,0 @@
-# frozen_string_literal: true
-
-desc "Migrate patients who were moved out of cohorts to ensure they're archived."
-task archive_moved_out_of_cohort_patients: :environment do
- Team
- .includes(:organisation)
- .find_each do |team|
- user =
- OpenStruct.new(
- selected_team: team,
- selected_organisation: team.organisation
- )
-
- patients_in_cohort = team.patients
- patients_associated_with_team =
- PatientPolicy::Scope.new(user, Patient).resolve
-
- patients_not_in_cohort =
- patients_associated_with_team - patients_in_cohort
-
- archive_reasons =
- patients_not_in_cohort.map do |patient|
- ArchiveReason.new(
- patient:,
- team:,
- type: "other",
- other_details: "Unknown: before reasons added"
- )
- end
-
- ArchiveReason.import!(archive_reasons, on_duplicate_key_ignore: true)
- end
-end
diff --git a/lib/tasks/gp_practices.rake b/lib/tasks/gp_practices.rake
index 6ac0a79a20..2622d4f83e 100644
--- a/lib/tasks/gp_practices.rake
+++ b/lib/tasks/gp_practices.rake
@@ -13,25 +13,23 @@ namespace :gp_practices do
csv_entry = zip.glob("*.csv").first
csv_content = csv_entry.get_input_stream.read
- total_rows = CSV.parse(csv_content).count
+ rows =
+ CSV.parse(csv_content, headers: false, encoding: "ISO-8859-1:UTF-8")
+
batch_size = 1000
locations = []
# rubocop:disable Rails/SaveBang
progress_bar =
ProgressBar.create(
- total: total_rows,
+ total: rows.length + 1,
format: "%a %b\u{15E7}%i %p%% %t",
progress_mark: " ",
remainder_mark: "\u{FF65}"
)
# rubocop:enable Rails/SaveBang
- CSV.parse(
- csv_content,
- headers: false,
- encoding: "ISO-8859-1:UTF-8"
- ) do |row|
+ rows.each do |row|
ods_code = row[0]
name = row[1]
address_line_1 = row[3]
diff --git a/lib/tasks/onboard.rake b/lib/tasks/onboard.rake
deleted file mode 100644
index 60d3a18e3c..0000000000
--- a/lib/tasks/onboard.rake
+++ /dev/null
@@ -1,14 +0,0 @@
-# frozen_string_literal: true
-
-desc "Onboard a team from a configuration file."
-task :onboard, [:filename] => :environment do |_, args|
- config = YAML.safe_load(File.read(args[:filename]))
-
- onboarding = Onboarding.new(config)
-
- if onboarding.valid?
- onboarding.save!
- else
- onboarding.errors.full_messages.each { |message| puts message }
- end
-end
diff --git a/lib/tasks/subteams.rake b/lib/tasks/subteams.rake
index cd73165c8b..07c3f0469d 100644
--- a/lib/tasks/subteams.rake
+++ b/lib/tasks/subteams.rake
@@ -8,18 +8,18 @@ namespace :subteams do
Usage:
rake subteams:create # Complete the prompts
- rake subteams:create[ods_code,name,email,phone]
+ rake subteams:create[workgroup,name,email,phone]
DESC
- task :create, %i[ods_code name email phone] => :environment do |_task, args|
+ task :create, %i[workgroup name email phone] => :environment do |_task, args|
include TaskHelpers
if args.to_a.empty? && $stdin.isatty && $stdout.isatty
- ods_code = prompt_user_for "Enter team ODS code:", required: true
+ workgroup = prompt_user_for "Enter team workgroup:", required: true
name = prompt_user_for "Enter subteam name:", required: true
email = prompt_user_for "Enter subteam email:", required: true
phone = prompt_user_for "Enter subteam phone:", required: true
elsif args.to_a.size == 4
- ods_code = args[:ods_code]
+ workgroup = args[:workgroup]
name = args[:name]
email = args[:email]
phone = args[:phone]
@@ -28,8 +28,7 @@ namespace :subteams do
end
ActiveRecord::Base.transaction do
- # TODO: Select the right team based on an identifier.
- team = Team.joins(:organisation).find_by!(organisation: { ods_code: })
+ team = Team.find_by!(workgroup:)
subteam = team.subteams.create!(name:, email:, phone:)
diff --git a/lib/tasks/teams.rake b/lib/tasks/teams.rake
deleted file mode 100644
index 9ced89d076..0000000000
--- a/lib/tasks/teams.rake
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-namespace :teams do
- desc "Add a programme to a team."
- task :add_programme, %i[ods_code type] => :environment do |_task, args|
- # TODO: Select the right team based on an identifier.
- team =
- Team.joins(:organisation).find_by!(
- organisation: {
- ods_code: args[:ods_code]
- }
- )
- programme = Programme.find_by!(type: args[:type])
-
- TeamProgramme.find_or_create_by!(team:, programme:)
-
- GenericClinicFactory.call(team: team.reload)
- end
-end
diff --git a/lib/tasks/users.rake b/lib/tasks/users.rake
index e98a4a2b4c..af110cb537 100644
--- a/lib/tasks/users.rake
+++ b/lib/tasks/users.rake
@@ -5,7 +5,7 @@ require_relative "../task_helpers"
namespace :users do
desc "Create a new user and add them to a team."
task :create,
- %i[email password given_name family_name team_ods_code fallback_role] =>
+ %i[email password given_name family_name workgroup fallback_role] =>
:environment do |_task, args|
include TaskHelpers
@@ -14,7 +14,7 @@ namespace :users do
password = prompt_user_for "Enter password:", required: true
given_name = prompt_user_for "Enter given name:", required: true
family_name = prompt_user_for "Enter family name:", required: true
- team_ods_code = prompt_user_for "Enter team ODS code:", required: true
+ workgroup = prompt_user_for "Enter team workgroup:", required: true
fallback_role =
prompt_user_for "Enter fallback role (nurse/admin):",
default: "nurse",
@@ -26,19 +26,13 @@ namespace :users do
password = args[:password]
given_name = args[:given_name]
family_name = args[:family_name]
- team_ods_code = args[:team_ods_code]
+ workgroup = args[:workgroup]
fallback_role = args[:fallback_role] || "nurse"
else
raise "Expected 5-6 arguments, got #{args.to_a.size}"
end
- # TODO: Select the right team based on an identifier.
- team =
- Team.joins(:organisation).find_by!(
- organisation: {
- ods_code: team_ods_code
- }
- )
+ team = Team.find_by(workgroup:)
user =
User.create!(email:, password:, family_name:, given_name:, fallback_role:)
diff --git a/package.json b/package.json
index 3190424257..e7acd76429 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
"govuk-frontend": "^5.11.1",
"idb": "^8.0.3",
"nhsuk-frontend": "^9.6.4",
- "sass": "^1.89.2",
+ "sass": "^1.90.0",
"stimulus": "^3.2.2",
"workbox-build": "^7.3.0"
},
diff --git a/script/rollover_training/create_sessions.sh b/script/rollover_training/create_sessions.sh
new file mode 100644
index 0000000000..6fdda7f336
--- /dev/null
+++ b/script/rollover_training/create_sessions.sh
@@ -0,0 +1,3 @@
+set -eu
+
+bin/mavis teams create-sessions rollovertraining
diff --git a/script/rollover_training/generate.sh b/script/rollover_training/generate.sh
new file mode 100644
index 0000000000..9069b4cc1d
--- /dev/null
+++ b/script/rollover_training/generate.sh
@@ -0,0 +1,3 @@
+set -eu
+
+bin/mavis generate cohort-imports -w rollovertraining -c 1000
diff --git a/script/rollover_training/set_up.sh b/script/rollover_training/set_up.sh
new file mode 100644
index 0000000000..b1a2f62d8f
--- /dev/null
+++ b/script/rollover_training/set_up.sh
@@ -0,0 +1,6 @@
+set -eu
+
+bin/rails vaccines:seed
+bin/mavis gias import
+bin/rails gp_practices:import
+bin/mavis teams onboard config/onboarding/rollover-training.yaml
diff --git a/script/set_up_coventry.sh b/script/set_up_coventry.sh
deleted file mode 100644
index 51074ef2a7..0000000000
--- a/script/set_up_coventry.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-set -eux
-
-bin/rails db:schema:load
-
-bin/rails vaccines:seed[hpv]
-bin/rails gp_practices:import
-bin/rails schools:import
-bin/rails onboard[config/onboarding/coventry-model-office.yaml]
diff --git a/spec/components/app_activity_log_component_spec.rb b/spec/components/app_activity_log_component_spec.rb
index 6b58abec71..ccc89244d8 100644
--- a/spec/components/app_activity_log_component_spec.rb
+++ b/spec/components/app_activity_log_component_spec.rb
@@ -589,12 +589,36 @@
created_at: Time.zone.parse("#2024-05-30 15:00")
)
- create(
- :vaccination_record,
+ patient.vaccination_status(
programme: hpv_programme,
+ academic_year: 2024
+ ).vaccinated!
+ end
+
+ it "does not render expired PSD card for vaccinated patient" do
+ expect(rendered).not_to have_content("expired")
+ end
+ end
+
+ context "with vaccinated but seasonal programme" do
+ before do
+ create(
+ :consent,
+ :given,
+ programme: flu_programme,
patient:,
- session:,
- performed_at: Time.zone.parse("#2024-05-31 12:00"),
+ parent: mum,
+ academic_year: 2024,
+ submitted_at: Time.zone.parse("#2024-05-30 12:00")
+ )
+
+ create(
+ :triage,
+ :ready_to_vaccinate,
+ programme: flu_programme,
+ patient:,
+ academic_year: 2024,
+ created_at: Time.zone.parse("#2024-05-30 14:30"),
performed_by: user
)
@@ -606,12 +630,18 @@
academic_year: 2024,
created_at: Time.zone.parse("#2024-05-30 15:00")
)
+
+ patient.vaccination_status(
+ programme: flu_programme,
+ academic_year: 2024
+ ).vaccinated!
end
include_examples "card",
- title: "PSD status expired",
+ title:
+ "Consent, health information, triage outcome and PSD status expired",
date: "31 August 2025 at 11:59pm",
- notes: "DOE, Sarah was not vaccinated.",
+ notes: "DOE, Sarah was vaccinated.",
programme: "Flu"
end
end
diff --git a/spec/components/app_patient_programmes_table_component_spec.rb b/spec/components/app_patient_programmes_table_component_spec.rb
new file mode 100644
index 0000000000..dbdae0110f
--- /dev/null
+++ b/spec/components/app_patient_programmes_table_component_spec.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+describe AppPatientProgrammesTableComponent do
+ subject(:rendered_component) { render_inline(component) }
+
+ let(:component) { described_class.new(patient, programmes:) }
+ let(:team) { create(:team, programmes:) }
+ let(:session) { create(:session, team:, programmes:) }
+
+ let(:today) { Date.new(2025, 9, 1) }
+
+ around { |example| travel_to(today) { example.run } }
+
+ context "for seasonal programmes" do
+ let(:patient) { create(:patient, session:) }
+ let(:programmes) { create_list(:programme, 1, :flu) }
+
+ it { should have_content("Vaccination programmes") }
+ it { should have_content("Flu (Winter 2025)") }
+ it { should_not have_content("Vaccinated") }
+ it { should have_content("Selected for the Year 2025 to 2026 Flu cohort") }
+
+ context "when vaccinated" do
+ let(:patient) { create(:patient, :vaccinated, session:) }
+
+ it { should have_content("Vaccinated") }
+ end
+
+ context "when vaccinated last year" do
+ let(:patient) { create(:patient, session:) }
+
+ before do
+ create(
+ :vaccination_record,
+ patient:,
+ programme: programmes.first,
+ performed_at: Time.zone.local(2024, 9, 1)
+ )
+ StatusUpdater.call(patient:)
+ end
+
+ it { should_not have_content("Vaccinated") }
+ end
+ end
+
+ context "for non-seasonal programmes" do
+ let(:patient) { create(:patient, session:, year_group: 8) }
+ let(:programmes) do
+ [
+ create(:programme, :hpv),
+ create(:programme, :menacwy),
+ create(:programme, :td_ipv)
+ ]
+ end
+
+ it { should have_content("Vaccination programmes") }
+ it { should have_content("HPV") }
+ it { should have_content("Td/IPV") }
+ it { should have_content("MenACWY") }
+
+ it do
+ expect(rendered_component).to have_content(
+ "Selected for the Year 2025 to 2026 HPV cohort"
+ ).once
+ end
+
+ it { should have_content("Eligibility starts 1 September 2026").twice }
+
+ context "when vaccinated" do
+ let(:patient) { create(:patient, :vaccinated, session:) }
+
+ it { should have_content("Vaccinated") }
+ end
+
+ context "when vaccinated last year" do
+ let(:patient) { create(:patient, session:) }
+
+ before do
+ create(
+ :vaccination_record,
+ patient:,
+ programme: programmes.first,
+ performed_at: Time.zone.local(2024, 9, 1)
+ )
+ StatusUpdater.call(patient:)
+ end
+
+ it { should have_content("Vaccinated") }
+ end
+ end
+end
diff --git a/spec/components/app_patient_search_result_card_component_spec.rb b/spec/components/app_patient_search_result_card_component_spec.rb
index 06bd4ab242..4046035b28 100644
--- a/spec/components/app_patient_search_result_card_component_spec.rb
+++ b/spec/components/app_patient_search_result_card_component_spec.rb
@@ -76,5 +76,19 @@
it { should have_text("Triage status\nFluNeeds triage") }
end
+
+ context "with a session status of unwell" do
+ before do
+ create(
+ :patient_vaccination_status,
+ :none_yet,
+ patient:,
+ programme:,
+ latest_session_status: "unwell"
+ )
+ end
+
+ it { should have_text("Programme outcome\nFluNo outcome yetUnwell") }
+ end
end
end
diff --git a/spec/components/app_patient_session_search_result_card_component_spec.rb b/spec/components/app_patient_session_search_result_card_component_spec.rb
index d2975fe844..a40fb98b03 100644
--- a/spec/components/app_patient_session_search_result_card_component_spec.rb
+++ b/spec/components/app_patient_session_search_result_card_component_spec.rb
@@ -93,6 +93,9 @@
context "when allowed to record attendance" do
before { stub_authorization(allowed: true) }
+ it { should_not have_text("Session outcome") }
+ it { should have_text("Programme outcome") }
+
it { should have_text("Action required\nRecord vaccination for HPV") }
it { should have_button("Attending") }
it { should have_button("Absent") }
@@ -108,6 +111,9 @@
context "when not allowed to record attendance" do
before { stub_authorization(allowed: false) }
+ it { should_not have_text("Session outcome") }
+ it { should have_text("Programme outcome") }
+
it { should have_text("Action required\nRecord vaccination for HPV") }
it { should_not have_button("Attending") }
it { should_not have_button("Absent") }
@@ -143,8 +149,11 @@
end
end
- context "when context is outcome" do
- let(:context) { :outcome }
+ context "when context is patients" do
+ let(:context) { :patients }
+
+ it { should have_text("Session outcome") }
+ it { should have_text("Programme outcome") }
context "and the programme is flu" do
let(:programme) { create(:programme, :flu) }
diff --git a/spec/components/app_patient_summary_component_spec.rb b/spec/components/app_patient_summary_component_spec.rb
index 143fa6080c..d504cef7e0 100644
--- a/spec/components/app_patient_summary_component_spec.rb
+++ b/spec/components/app_patient_summary_component_spec.rb
@@ -19,6 +19,21 @@
it { should have_content("SELDON, Hari") }
+ it { should have_content("NHS number") }
+
+ context "when patient has an NHS number" do
+ before { patient.update(nhs_number: "9993425389") }
+
+ it { should have_content("999 342 5389") }
+ it { should_not have_link("Add the child's NHS number") }
+ end
+
+ context "when patient does not have an NHS number" do
+ before { patient.update(nhs_number: nil) }
+
+ it { should have_link("Add the child's NHS number") }
+ end
+
it { should have_content("Date of birth") }
it { should have_content("1 January 2000") }
diff --git a/spec/components/app_programme_status_tags_component_spec.rb b/spec/components/app_programme_status_tags_component_spec.rb
index d63d4b0715..08ea4ab953 100644
--- a/spec/components/app_programme_status_tags_component_spec.rb
+++ b/spec/components/app_programme_status_tags_component_spec.rb
@@ -3,28 +3,53 @@
describe AppProgrammeStatusTagsComponent do
subject { render_inline(component) }
- let(:component) { described_class.new(programme_statuses, outcome: :consent) }
-
let(:menacwy_programme) { create(:programme, :menacwy) }
let(:td_ipv_programme) { create(:programme, :td_ipv) }
let(:flu_programme) { create(:programme, :flu) }
- let(:programme_statuses) do
- {
- menacwy_programme => {
- status: :given
- },
- td_ipv_programme => {
- status: :refused
- },
- flu_programme => {
- status: :given,
- vaccine_methods: %w[nasal injection]
+ context "for consent outcome" do
+ let(:component) do
+ described_class.new(programme_statuses, outcome: :consent)
+ end
+
+ let(:programme_statuses) do
+ {
+ menacwy_programme => {
+ status: :given
+ },
+ td_ipv_programme => {
+ status: :refused
+ },
+ flu_programme => {
+ status: :given,
+ vaccine_methods: %w[nasal injection]
+ }
}
- }
+ end
+
+ it { should have_content("MenACWYConsent given") }
+ it { should have_content("Td/IPVConsent refused") }
+ it { should have_content("FluConsent givenNasal spray") }
end
- it { should have_content("MenACWYConsent given") }
- it { should have_content("Td/IPVConsent refused") }
- it { should have_content("FluConsent givenNasal spray") }
+ context "for programme outcome" do
+ let(:component) do
+ described_class.new(programme_statuses, outcome: :programme)
+ end
+
+ let(:programme_statuses) do
+ {
+ menacwy_programme => {
+ status: :vaccinated
+ },
+ td_ipv_programme => {
+ status: :none_yet,
+ latest_session_status: :unwell
+ }
+ }
+ end
+
+ it { should have_content("MenACWYVaccinated") }
+ it { should have_content("Td/IPVNo outcome yetUnwell") }
+ end
end
diff --git a/spec/components/app_session_actions_component_spec.rb b/spec/components/app_session_actions_component_spec.rb
index 201acdc5db..69eaecac6c 100644
--- a/spec/components/app_session_actions_component_spec.rb
+++ b/spec/components/app_session_actions_component_spec.rb
@@ -10,6 +10,10 @@
let(:year_group) { 8 }
+ let(:patient_without_nhs_number) do
+ create(:patient, nhs_number: nil, year_group:)
+ end
+
before do
create(
:patient_session,
@@ -40,19 +44,28 @@
year_group:
)
create(:patient_session, :vaccinated, :in_attendance, session:, year_group:)
+
+ create(
+ :patient_session,
+ patient: patient_without_nhs_number,
+ session:,
+ year_group:
+ )
end
+ it { should have_text("No NHS number\n1 child") }
it { should have_text("No consent response\n1 child") }
it { should have_text("Conflicting consent\n1 child") }
it { should have_text("Triage needed\n1 child") }
it { should have_text("Register attendance\n3 child") }
it { should have_text("Ready for vaccinator\n1 child for HPV") }
- it { should have_link("Review no consent response") }
- it { should have_link("Review conflicting consent") }
- it { should have_link("Review triage needed") }
- it { should have_link("Review register attendance") }
- it { should have_link("Review ready for vaccinator") }
+ it { should have_link("1 child without an NHS number") }
+ it { should have_link("1 child with no response") }
+ it { should have_link("1 child with conflicting response") }
+ it { should have_link("1 child requiring triage") }
+ it { should have_link("3 children to register") }
+ it { should have_link("1 child for HPV") }
context "session requires no registration" do
let(:session) { create(:session, :requires_no_registration, programmes:) }
diff --git a/spec/components/app_session_card_component_spec.rb b/spec/components/app_session_card_component_spec.rb
index ff35167271..d435be0351 100644
--- a/spec/components/app_session_card_component_spec.rb
+++ b/spec/components/app_session_card_component_spec.rb
@@ -1,11 +1,9 @@
# frozen_string_literal: true
describe AppSessionCardComponent do
- subject(:rendered) { render_inline(component) }
+ subject(:rendered) { travel_to(today) { render_inline(component) } }
- let(:component) do
- travel_to(today) { described_class.new(session, patient_count: 100) }
- end
+ let(:component) { described_class.new(session, patient_count: 100) }
let(:today) { Date.new(2025, 7, 1) }
diff --git a/spec/components/app_session_needs_review_warning_component_spec.rb b/spec/components/app_session_needs_review_warning_component_spec.rb
new file mode 100644
index 0000000000..6cd7a64a6e
--- /dev/null
+++ b/spec/components/app_session_needs_review_warning_component_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+describe AppSessionNeedsReviewWarningComponent do
+ let(:component) { described_class.new(session:) }
+ let(:session) { create(:session) }
+
+ describe "#render?" do
+ subject { component.render? }
+
+ context "when session has no patients without NHS number" do
+ it { should be(false) }
+ end
+
+ context "when session has patients without NHS number" do
+ before { create(:patient, nhs_number: nil, session:) }
+
+ it { should be(true) }
+ end
+ end
+
+ describe "rendered content" do
+ subject(:rendered) { render_inline(component) }
+
+ context "when session has patients without NHS number" do
+ before do
+ create(
+ :patient,
+ nhs_number: nil,
+ patient_sessions: [build(:patient_session, session:)],
+ year_group: session.programmes.sample.default_year_groups.sample
+ )
+ end
+
+ it "shows the count of children without NHS number" do
+ expect(rendered).to have_text("1")
+ end
+
+ it "has a link to the consent page with missing_nhs_number parameter" do
+ expect(rendered).to have_link(href: /missing_nhs_number=true/)
+ end
+
+ it "uses the correct translation key for the link text" do
+ expect(rendered).to have_text(
+ I18n.t(:children_without_nhs_number, count: 1)
+ )
+ end
+ end
+
+ context "when session has multiple patients without NHS number" do
+ before { create_list(:patient, 3, nhs_number: nil, session:) }
+
+ it "shows the correct count of children without NHS number" do
+ expect(rendered).to have_text("3")
+ end
+ end
+ end
+end
diff --git a/spec/components/app_vaccination_record_api_sync_status_component_spec.rb b/spec/components/app_vaccination_record_api_sync_status_component_spec.rb
index 7d5e1ecb11..3fd724d819 100644
--- a/spec/components/app_vaccination_record_api_sync_status_component_spec.rb
+++ b/spec/components/app_vaccination_record_api_sync_status_component_spec.rb
@@ -15,6 +15,23 @@
subject(:formatted_status) { rendered_component.to_html }
context "when sync_status is :not_synced" do
+ context "when vaccination has notify_parents nil (and is a historic record)" do
+ before do
+ allow(vaccination_record).to receive_messages(
+ sync_status: :not_synced,
+ notify_parents: nil
+ )
+ end
+
+ let(:session) { nil }
+
+ it do
+ expect(formatted_status).to include(
+ "Records are not synced if the vaccination was not recorded in Mavis"
+ )
+ end
+ end
+
context "when vaccination was not administered" do
before do
allow(vaccination_record).to receive_messages(
@@ -77,6 +94,23 @@
)
end
end
+
+ context "when vaccination has notify_parents false, but the programme is not sent to the API anyway" do
+ before do
+ allow(vaccination_record).to receive_messages(
+ sync_status: :not_synced,
+ notify_parents: false
+ )
+ end
+
+ let(:programme) { create(:programme, type: "menacwy") }
+
+ it do
+ expect(formatted_status).to include(
+ "Records are currently not synced for this programme"
+ )
+ end
+ end
end
context "when sync_status is :cannot_sync" do
diff --git a/spec/controllers/api/testing/teams_controller_spec.rb b/spec/controllers/api/testing/teams_controller_spec.rb
index a1ddebd99c..5935538b82 100644
--- a/spec/controllers/api/testing/teams_controller_spec.rb
+++ b/spec/controllers/api/testing/teams_controller_spec.rb
@@ -10,7 +10,13 @@
let(:programmes) { [create(:programme, :hpv_all_vaccines)] }
let(:team) do
- create(:team, :with_generic_clinic, ods_code: "R1L", programmes:)
+ create(
+ :team,
+ :with_generic_clinic,
+ ods_code: "R1L",
+ workgroup: "r1l",
+ programmes:
+ )
end
let(:cohort_import) do
@@ -39,11 +45,11 @@
end
end
- TeamSessionsFactory.call(team, academic_year: AcademicYear.current)
-
create(:school, urn: "123456", team:, programmes:) # to match cohort_import/valid.csv
create(:school, urn: "110158", team:, programmes:) # to match valid_hpv.csv
+ TeamSessionsFactory.call(team, academic_year: AcademicYear.current)
+
cohort_import.process!
immunisation_import.process!
@@ -69,11 +75,11 @@
end
it "deletes associated data" do
- expect { delete :destroy, params: { ods_code: "r1l" } }.to(
+ expect { delete :destroy, params: { workgroup: "r1l" } }.to(
change(Team, :count)
.by(-1)
.and(change(Subteam, :count).by(-1))
- .and(change(Session, :count).by(-1))
+ .and(change(Session, :count).by(-3))
.and(change(CohortImport, :count).by(-1))
.and(change(ImmunisationImport, :count).by(-1))
.and(change(NotifyLogEntry, :count).by(-3))
@@ -87,7 +93,7 @@
context "when keeping itself" do
subject(:call) do
- delete :destroy, params: { ods_code: "r1l", keep_itself: "true" }
+ delete :destroy, params: { workgroup: "r1l", keep_itself: "true" }
end
it "deletes associated data" do
diff --git a/spec/factories/one_time_tokens.rb b/spec/factories/one_time_tokens.rb
new file mode 100644
index 0000000000..5fd03c47af
--- /dev/null
+++ b/spec/factories/one_time_tokens.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: reporting_api_one_time_tokens
+#
+# cis2_info :jsonb not null
+# token :string not null, primary key
+# created_at :datetime not null
+# updated_at :datetime not null
+# user_id :bigint not null
+#
+# Indexes
+#
+# index_reporting_api_one_time_tokens_on_created_at (created_at)
+# index_reporting_api_one_time_tokens_on_token (token) UNIQUE
+# index_reporting_api_one_time_tokens_on_user_id (user_id) UNIQUE
+#
+# Foreign Keys
+#
+# fk_rails_... (user_id => users.id)
+FactoryBot.define do
+ factory :reporting_api_one_time_token, class: ReportingAPI::OneTimeToken do
+ transient { prefix { Faker::Alphanumeric.alpha(number: 2).upcase } }
+
+ user
+ token { Faker::Number.hexadecimal(digits: 32) }
+ end
+end
diff --git a/spec/factories/patient_vaccination_statuses.rb b/spec/factories/patient_vaccination_statuses.rb
index e689ebee76..174cb22b26 100644
--- a/spec/factories/patient_vaccination_statuses.rb
+++ b/spec/factories/patient_vaccination_statuses.rb
@@ -4,11 +4,12 @@
#
# Table name: patient_vaccination_statuses
#
-# id :bigint not null, primary key
-# academic_year :integer not null
-# status :integer default("none_yet"), not null
-# patient_id :bigint not null
-# programme_id :bigint not null
+# id :bigint not null, primary key
+# academic_year :integer not null
+# latest_session_status :integer default("none_yet"), not null
+# status :integer default("none_yet"), not null
+# patient_id :bigint not null
+# programme_id :bigint not null
#
# Indexes
#
diff --git a/spec/factories/teams.rb b/spec/factories/teams.rb
index c94d60b15e..186c31baae 100644
--- a/spec/factories/teams.rb
+++ b/spec/factories/teams.rb
@@ -15,6 +15,7 @@
# phone_instructions :string
# privacy_notice_url :string not null
# privacy_policy_url :string not null
+# workgroup :string not null
# created_at :datetime not null
# updated_at :datetime not null
# organisation_id :bigint not null
@@ -24,6 +25,7 @@
#
# index_teams_on_name (name) UNIQUE
# index_teams_on_organisation_id (organisation_id)
+# index_teams_on_workgroup (workgroup) UNIQUE
#
# Foreign Keys
#
@@ -38,6 +40,7 @@
organisation { association(:organisation, ods_code:) }
+ workgroup { "w#{identifier}" }
name { "SAIS Team #{identifier}" }
email { "sais-team-#{identifier}@example.com" }
phone { "01234 567890" }
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
index e3690d5a3f..f94abe1f9c 100644
--- a/spec/factories/users.rb
+++ b/spec/factories/users.rb
@@ -4,28 +4,30 @@
#
# Table name: users
#
-# id :bigint not null, primary key
-# current_sign_in_at :datetime
-# current_sign_in_ip :string
-# email :string
-# encrypted_password :string default(""), not null
-# fallback_role :integer default("nurse"), not null
-# family_name :string not null
-# given_name :string not null
-# last_sign_in_at :datetime
-# last_sign_in_ip :string
-# provider :string
-# remember_created_at :datetime
-# session_token :string
-# sign_in_count :integer default(0), not null
-# uid :string
-# created_at :datetime not null
-# updated_at :datetime not null
+# id :bigint not null, primary key
+# current_sign_in_at :datetime
+# current_sign_in_ip :string
+# email :string
+# encrypted_password :string default(""), not null
+# fallback_role :integer default("nurse"), not null
+# family_name :string not null
+# given_name :string not null
+# last_sign_in_at :datetime
+# last_sign_in_ip :string
+# provider :string
+# remember_created_at :datetime
+# reporting_api_session_token :string
+# session_token :string
+# sign_in_count :integer default(0), not null
+# uid :string
+# created_at :datetime not null
+# updated_at :datetime not null
#
# Indexes
#
-# index_users_on_email (email) UNIQUE
-# index_users_on_provider_and_uid (provider,uid) UNIQUE
+# index_users_on_email (email) UNIQUE
+# index_users_on_provider_and_uid (provider,uid) UNIQUE
+# index_users_on_reporting_api_session_token (reporting_api_session_token) UNIQUE
#
FactoryBot.define do
factory :user,
diff --git a/spec/features/access_log_spec.rb b/spec/features/access_log_spec.rb
index 6158786243..fd32b04c0e 100644
--- a/spec/features/access_log_spec.rb
+++ b/spec/features/access_log_spec.rb
@@ -33,7 +33,7 @@
def given_i_am_signed_in
programmes = [create(:programme, :hpv)]
- team = create(:team, :with_one_nurse, programmes:)
+ team = create(:team, :with_generic_clinic, :with_one_nurse, programmes:)
@user = team.users.first
diff --git a/spec/features/archive_children_spec.rb b/spec/features/archive_children_spec.rb
index eb6f0c07fe..e571915765 100644
--- a/spec/features/archive_children_spec.rb
+++ b/spec/features/archive_children_spec.rb
@@ -111,7 +111,7 @@
def given_an_team_exists
programmes = [create(:programme, :flu)]
- @team = create(:team, programmes:)
+ @team = create(:team, :with_generic_clinic, programmes:)
@session = create(:session, team: @team, programmes:)
end
diff --git a/spec/features/cli_clinics_add_to_team_spec.rb b/spec/features/cli_clinics_add_to_team_spec.rb
index 27e352aad7..77d49b1f04 100644
--- a/spec/features/cli_clinics_add_to_team_spec.rb
+++ b/spec/features/cli_clinics_add_to_team_spec.rb
@@ -44,12 +44,12 @@
def command
Dry::CLI.new(MavisCLI).call(
- arguments: %w[clinics add-to-team ABC Team 123456]
+ arguments: %w[clinics add-to-team abc Team 123456]
)
end
def given_the_team_exists
- @team = create(:team, ods_code: "ABC")
+ @team = create(:team, workgroup: "abc")
end
def and_the_subteam_exists
diff --git a/spec/features/cli_generate_cohort_imports_spec.rb b/spec/features/cli_generate_cohort_imports_spec.rb
index 665d003718..671602981f 100644
--- a/spec/features/cli_generate_cohort_imports_spec.rb
+++ b/spec/features/cli_generate_cohort_imports_spec.rb
@@ -9,18 +9,12 @@
end
def given_an_organisation_exists
- @programme = Programme.hpv.first || create(:programme, :hpv)
- @organisation = create(:organisation, ods_code: "R1Y")
+ @programme = create(:programme, :hpv)
+ @team = create(:team, workgroup: "r1y", programmes: [@programme])
end
def and_there_are_three_sessions_in_the_organisation
- @sessions =
- create_list(
- :session,
- 3,
- organisation: @organisation,
- programmes: [@programme]
- )
+ @sessions = create_list(:session, 3, team: @team, programmes: [@programme])
end
def when_i_run_the_generate_cohort_imports_command
@@ -28,7 +22,7 @@ def when_i_run_the_generate_cohort_imports_command
@output =
capture_output do
Dry::CLI.new(MavisCLI).call(
- arguments: %w[generate cohort-imports -o R1Y -p 100]
+ arguments: %w[generate cohort-imports -w r1y -c 100]
)
end
@timestamp = Time.current.strftime("%Y%m%d%H%M%S")
@@ -37,15 +31,15 @@ def when_i_run_the_generate_cohort_imports_command
def then_a_cohort_import_csv_file_is_created
expect(@output).to include(
- "Generating cohort import for ods code R1Y with 100 patients"
+ "Generating cohort import for team r1y with 100 patients"
)
expect(@output).to match(
- /Cohort import CSV generated:.*cohort-import-R1Y-hpv-100-#{@timestamp}.csv/
+ /Cohort import CSV generated:.*cohort-import-r1y-hpv-100-#{@timestamp}.csv/
)
expect(
File.readlines(
- Rails.root.join("tmp", "cohort-import-R1Y-hpv-100-#{@timestamp}.csv")
+ Rails.root.join("tmp", "cohort-import-r1y-hpv-100-#{@timestamp}.csv")
).length
).to eq 101
end
diff --git a/spec/features/cli_generate_consents_spec.rb b/spec/features/cli_generate_consents_spec.rb
index f30ba04391..f25e0ceb9c 100644
--- a/spec/features/cli_generate_consents_spec.rb
+++ b/spec/features/cli_generate_consents_spec.rb
@@ -35,8 +35,8 @@ def when_i_run_the_generate_consents_command
arguments: [
"generate",
"consents",
- "-o",
- @team.organisation.ods_code.to_s,
+ "-w",
+ @team.workgroup,
"-p",
@programme.type,
"-s",
diff --git a/spec/features/cli_generate_vaccination_records_spec.rb b/spec/features/cli_generate_vaccination_records_spec.rb
index a41b389030..f1be370ec5 100644
--- a/spec/features/cli_generate_vaccination_records_spec.rb
+++ b/spec/features/cli_generate_vaccination_records_spec.rb
@@ -38,8 +38,8 @@ def when_i_run_the_generate_vaccination_records_command
arguments: [
"generate",
"vaccination-records",
- "-o",
- @team.organisation.ods_code.to_s,
+ "-w",
+ @team.workgroup,
"-p",
@programme.type,
"-s",
diff --git a/spec/features/cli_schools_add_to_team_spec.rb b/spec/features/cli_schools_add_to_team_spec.rb
index cfc0d0d500..3359d50c4d 100644
--- a/spec/features/cli_schools_add_to_team_spec.rb
+++ b/spec/features/cli_schools_add_to_team_spec.rb
@@ -55,19 +55,19 @@
def command
Dry::CLI.new(MavisCLI).call(
- arguments: %w[schools add-to-team ABC Team 123456]
+ arguments: %w[schools add-to-team abc Team 123456]
)
end
def command_with_flu_only
Dry::CLI.new(MavisCLI).call(
- arguments: %w[schools add-to-team ABC Team 123456 --programmes flu]
+ arguments: %w[schools add-to-team abc Team 123456 --programmes flu]
)
end
def given_the_team_exists
@programmes = [create(:programme, :flu), create(:programme, :hpv)]
- @team = create(:team, ods_code: "ABC", programmes: @programmes)
+ @team = create(:team, workgroup: "abc", programmes: @programmes)
end
def and_the_subteam_exists
diff --git a/spec/features/cli_teams_add_programme_spec.rb b/spec/features/cli_teams_add_programme_spec.rb
index ccdf623529..bca0778d04 100644
--- a/spec/features/cli_teams_add_programme_spec.rb
+++ b/spec/features/cli_teams_add_programme_spec.rb
@@ -3,17 +3,8 @@
require_relative "../../app/lib/mavis_cli"
describe "mavis teams add-programme" do
- context "when the organisation doesn't exist" do
- it "displays an error message" do
- when_i_run_the_command_expecting_an_error
- then_an_organisation_not_found_error_message_is_displayed
- end
- end
-
context "when the team doesn't exist" do
it "displays an error message" do
- given_the_organisation_exists
-
when_i_run_the_command_expecting_an_error
then_a_team_not_found_error_message_is_displayed
end
@@ -21,8 +12,7 @@
context "when the programme doesn't exist" do
it "displays an error message" do
- given_the_organisation_exists
- and_the_team_exists
+ given_the_team_exists
when_i_run_the_command_expecting_an_error
then_a_programme_not_found_error_message_is_displayed
@@ -31,8 +21,7 @@
context "when the programme exists" do
it "runs successfully" do
- given_the_organisation_exists
- and_the_team_exists
+ given_the_team_exists
and_the_programme_exists
when_i_run_the_command
@@ -43,15 +32,11 @@
private
def command
- Dry::CLI.new(MavisCLI).call(arguments: %w[teams add-programme ABC Team flu])
- end
-
- def given_the_organisation_exists
- @organisation = create(:organisation, ods_code: "ABC")
+ Dry::CLI.new(MavisCLI).call(arguments: %w[teams add-programme abc flu])
end
- def and_the_team_exists
- @team = create(:team, organisation: @organisation, name: "Team")
+ def given_the_team_exists
+ @team = create(:team, workgroup: "abc")
@school = create(:school, :secondary, team: @team)
end
@@ -67,10 +52,6 @@ def when_i_run_the_command_expecting_an_error
@output = capture_error { command }
end
- def then_an_organisation_not_found_error_message_is_displayed
- expect(@output).to include("Could not find organisation.")
- end
-
def then_a_team_not_found_error_message_is_displayed
expect(@output).to include("Could not find team.")
end
diff --git a/spec/features/cli_teams_create_sessions_spec.rb b/spec/features/cli_teams_create_sessions_spec.rb
index 0dc3d880ef..9f040729be 100644
--- a/spec/features/cli_teams_create_sessions_spec.rb
+++ b/spec/features/cli_teams_create_sessions_spec.rb
@@ -3,17 +3,8 @@
require_relative "../../app/lib/mavis_cli"
describe "mavis teams create-sessions" do
- context "when the organisation doesn't exist" do
- it "displays an error message" do
- when_i_run_the_command_expecting_an_error
- then_an_organisation_not_found_error_message_is_displayed
- end
- end
-
context "when the team doesn't exist" do
it "displays an error message" do
- given_the_organisation_exists
-
when_i_run_the_command_expecting_an_error
then_a_team_not_found_error_message_is_displayed
end
@@ -21,9 +12,8 @@
context "when the team exists" do
it "runs successfully" do
- given_the_organisation_exists
- and_the_team_exists
- and_the_school_exists
+ given_the_team_exists
+ and_a_school_exists
when_i_run_the_command
then_the_school_session_is_created
@@ -33,25 +23,15 @@
private
def command
- Dry::CLI.new(MavisCLI).call(arguments: %w[teams create-sessions ABC Team])
- end
-
- def given_the_organisation_exists
- @organisation = create(:organisation, ods_code: "ABC")
+ Dry::CLI.new(MavisCLI).call(arguments: %w[teams create-sessions abc])
end
- def and_the_team_exists
+ def given_the_team_exists
@programmes = [create(:programme, :flu), create(:programme, :hpv)]
- @team =
- create(
- :team,
- organisation: @organisation,
- name: "Team",
- programmes: @programmes
- )
+ @team = create(:team, workgroup: "abc", programmes: @programmes)
end
- def and_the_school_exists
+ def and_a_school_exists
@school = create(:school, name: "School", urn: "123456", team: @team)
end
diff --git a/spec/features/cli_teams_list_spec.rb b/spec/features/cli_teams_list_spec.rb
new file mode 100644
index 0000000000..0729b7ca04
--- /dev/null
+++ b/spec/features/cli_teams_list_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require_relative "../../app/lib/mavis_cli"
+
+describe "mavis teams list" do
+ it "lists all teams" do
+ given_a_couple_organisations_exist
+ and_there_are_teams_in_the_organisations
+ when_i_run_the_list_teams_command
+ then_i_should_see_the_list_of_teams
+ end
+
+ it "lists teams for one org" do
+ given_a_couple_organisations_exist
+ and_there_are_teams_in_the_organisations
+ when_i_run_the_list_teams_command_with_an_ods_code
+ then_i_should_see_the_teams_for_just_that_ods_code
+ end
+
+ def given_a_couple_organisations_exist
+ @organisation1 = create(:organisation)
+ @organisation2 = create(:organisation)
+ end
+
+ def and_there_are_teams_in_the_organisations
+ @team1 = create(:team, organisation: @organisation1)
+ @team2 = create(:team, organisation: @organisation2)
+ end
+
+ def when_i_run_the_list_teams_command
+ @output =
+ capture_output { Dry::CLI.new(MavisCLI).call(arguments: %w[teams list]) }
+ end
+
+ def when_i_run_the_list_teams_command_with_an_ods_code
+ @output =
+ capture_output do
+ Dry::CLI.new(MavisCLI).call(
+ arguments: ["teams", "list", "-o", @organisation1.ods_code]
+ )
+ end
+ end
+
+ def then_i_should_see_the_list_of_teams
+ expect(@output).to include(@team1.name)
+ expect(@output).to include(@organisation1.ods_code)
+ expect(@output).to include(@team1.workgroup)
+ expect(@output).to include(@team2.name)
+ expect(@output).to include(@organisation2.ods_code)
+ expect(@output).to include(@team2.workgroup)
+ end
+
+ def then_i_should_see_the_teams_for_just_that_ods_code
+ expect(@output).to include(@team1.name)
+ expect(@output).to include(@organisation1.ods_code)
+ expect(@output).to include(@team1.workgroup)
+ expect(@output).not_to include(@team2.name)
+ expect(@output).not_to include(@organisation2.ods_code)
+ expect(@output).not_to include(@team2.workgroup)
+ end
+end
diff --git a/spec/features/cli_teams_onboard_spec.rb b/spec/features/cli_teams_onboard_spec.rb
new file mode 100644
index 0000000000..bda6359dc4
--- /dev/null
+++ b/spec/features/cli_teams_onboard_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require_relative "../../app/lib/mavis_cli"
+
+describe "mavis teams onboard" do
+ context "with a valid configuration" do
+ it "runs successfully" do
+ given_programmes_and_schools_exist
+ when_i_run_the_valid_command
+ then_i_see_no_output
+ and_a_new_team_is_created
+ end
+ end
+
+ context "with an invalid configuration" do
+ it "displays an error message" do
+ when_i_run_the_invalid_command
+ then_i_see_an_error_message
+ end
+ end
+
+ def command_with_valid_configuration
+ Dry::CLI.new(MavisCLI).call(
+ arguments: %w[teams onboard spec/fixtures/files/onboarding/valid.yaml]
+ )
+ end
+
+ def command_with_invalid_configuration
+ Dry::CLI.new(MavisCLI).call(
+ arguments: %w[teams onboard spec/fixtures/files/onboarding/invalid.yaml]
+ )
+ end
+
+ def given_programmes_and_schools_exist
+ create(:programme, :hpv)
+
+ create(:school, :secondary, :open, urn: "123456")
+ create(:school, :secondary, :open, urn: "234567")
+ create(:school, :secondary, :open, urn: "345678")
+ create(:school, :secondary, :open, urn: "456789")
+ end
+
+ def when_i_run_the_valid_command
+ @output = capture_output { command_with_valid_configuration }
+ end
+
+ def when_i_run_the_invalid_command
+ @output = capture_output { command_with_invalid_configuration }
+ end
+
+ def then_i_see_no_output
+ expect(@output).to be_empty
+ end
+
+ def and_a_new_team_is_created
+ expect(Team.count).to eq(1)
+ end
+
+ def then_i_see_an_error_message
+ expect(@output).to include("Programmes can't be blank")
+ end
+end
diff --git a/spec/features/delete_vaccination_record_spec.rb b/spec/features/delete_vaccination_record_spec.rb
index 9cd12c96a1..a7a21d3e72 100644
--- a/spec/features/delete_vaccination_record_spec.rb
+++ b/spec/features/delete_vaccination_record_spec.rb
@@ -113,7 +113,7 @@
end
def given_an_hpv_programme_is_underway
- @team = create(:team, :with_one_nurse)
+ @team = create(:team, :with_generic_clinic, :with_one_nurse)
@programme = create(:programme, :hpv, teams: [@team])
@session =
@@ -192,8 +192,8 @@ def when_i_sign_in_as_a_superuser
end
def and_i_go_to_a_patient_that_is_vaccinated_in_the_session
- visit session_outcome_path(@session)
- choose "Vaccinated"
+ visit session_patients_path(@session)
+ choose "Vaccinated", match: :first
click_on "Update results"
click_on @patient.full_name
end
diff --git a/spec/features/doubles_vaccination_administered_spec.rb b/spec/features/doubles_vaccination_administered_spec.rb
index 8031d3814c..e4b3cfb711 100644
--- a/spec/features/doubles_vaccination_administered_spec.rb
+++ b/spec/features/doubles_vaccination_administered_spec.rb
@@ -121,7 +121,7 @@ def then_i_see_the_patient_is_vaccinated_for_td_ipv
click_on "Record vaccinations"
expect(page).to have_content("No children matching search criteria found")
- click_on "Session outcomes"
+ within(".app-secondary-navigation") { click_on "Children" }
click_on @patient.full_name
expect(page).to have_content("Vaccinated")
end
diff --git a/spec/features/e2e_journey_spec.rb b/spec/features/e2e_journey_spec.rb
index 6752ccf7cc..89ba594ccf 100644
--- a/spec/features/e2e_journey_spec.rb
+++ b/spec/features/e2e_journey_spec.rb
@@ -259,8 +259,8 @@ def and_i_record_the_successful_vaccination
def then_i_see_that_the_child_is_vaccinated
click_on "Pilot School"
- click_on "Session outcomes"
- choose "Vaccinated"
+ within(".app-secondary-navigation") { click_on "Children" }
+ choose "Vaccinated", match: :first
click_on "Update results"
expect(page).to have_content("Showing 1 to 1 of 1 children")
diff --git a/spec/features/edit_parent_spec.rb b/spec/features/edit_parent_spec.rb
index 2c3c130a99..2ffe5076de 100644
--- a/spec/features/edit_parent_spec.rb
+++ b/spec/features/edit_parent_spec.rb
@@ -38,10 +38,12 @@
end
def given_a_patient_with_a_parent_exists
- team = create(:team)
+ programmes = [create(:programme)]
+
+ team = create(:team, :with_generic_clinic, programmes:)
@nurse = create(:nurse, team:)
- session = create(:session, team:)
+ session = create(:session, team:, programmes:)
@patient = create(:patient, session:)
@parent = create(:parent)
diff --git a/spec/features/edit_vaccination_record_spec.rb b/spec/features/edit_vaccination_record_spec.rb
index 912b67df32..6fe7fdc782 100644
--- a/spec/features/edit_vaccination_record_spec.rb
+++ b/spec/features/edit_vaccination_record_spec.rb
@@ -1,9 +1,10 @@
# frozen_string_literal: true
describe "Edit vaccination record" do
+ before { given_an_hpv_programme_is_underway }
+
scenario "User edits a new vaccination record" do
given_i_am_signed_in
- and_an_hpv_programme_is_underway
and_an_administered_vaccination_record_exists
and_enqueue_sync_vaccination_records_to_nhs_feature_is_enabled
@@ -47,7 +48,6 @@
scenario "User edits a vaccination record that already received confirmation" do
given_i_am_signed_in
- and_an_hpv_programme_is_underway
and_an_administered_vaccination_record_exists
and_the_vaccination_confirmation_was_already_sent
@@ -75,7 +75,6 @@
scenario "User edits a vaccination record, not enough to trigger an email" do
given_i_am_signed_in
- and_an_hpv_programme_is_underway
and_an_administered_vaccination_record_exists
and_the_vaccination_confirmation_was_already_sent
@@ -95,7 +94,6 @@
scenario "Edit outcome to vaccinated" do
given_i_am_signed_in
- and_an_hpv_programme_is_underway
and_enqueue_sync_vaccination_records_to_nhs_feature_is_enabled
and_a_not_administered_vaccination_record_exists
and_the_vaccination_confirmation_was_already_sent
@@ -128,7 +126,6 @@
scenario "Edit outcome to not vaccinated" do
given_i_am_signed_in
- and_an_hpv_programme_is_underway
and_enqueue_sync_vaccination_records_to_nhs_feature_is_enabled
and_an_administered_vaccination_record_exists
and_the_vaccination_confirmation_was_already_sent
@@ -152,7 +149,6 @@
scenario "With an archived batch" do
given_i_am_signed_in
- and_an_hpv_programme_is_underway
and_an_administered_vaccination_record_exists
and_the_original_batch_has_been_archived
@@ -170,7 +166,6 @@
scenario "With an expired batch" do
given_i_am_signed_in
- and_an_hpv_programme_is_underway
and_an_administered_vaccination_record_exists
and_the_original_batch_has_expired
@@ -188,7 +183,6 @@
scenario "Cannot as an admin" do
given_i_am_signed_in_as_an_admin
- and_an_hpv_programme_is_underway
and_an_administered_vaccination_record_exists
when_i_go_to_the_vaccination_record_for_the_patient
@@ -197,7 +191,6 @@
scenario "Navigating back" do
given_i_am_signed_in
- and_an_hpv_programme_is_underway
and_an_administered_vaccination_record_exists
when_i_go_to_the_vaccination_record_for_the_patient
@@ -214,18 +207,17 @@
then_i_should_see_the_vaccination_record
end
- def given_i_am_signed_in
- @team = create(:team, :with_one_nurse, ods_code: "R1L")
- sign_in @team.users.first
- end
-
- def given_i_am_signed_in_as_an_admin
- @team = create(:team, :with_one_admin, ods_code: "R1L")
- sign_in @team.users.first, role: :admin_staff
- end
+ def given_an_hpv_programme_is_underway
+ @programme = create(:programme, :hpv)
- def and_an_hpv_programme_is_underway
- @programme = create(:programme, :hpv, teams: [@team])
+ @team =
+ create(
+ :team,
+ :with_generic_clinic,
+ :with_one_nurse,
+ ods_code: "R1L",
+ programmes: [@programme]
+ )
@vaccine = @programme.vaccines.first
@@ -251,11 +243,16 @@ def and_an_hpv_programme_is_underway
given_name: "John",
family_name: "Smith",
team: @team,
- programmes: [@programme]
+ session: @session
)
+ end
- @patient_session =
- create(:patient_session, patient: @patient, session: @session)
+ def given_i_am_signed_in
+ sign_in @team.users.first
+ end
+
+ def given_i_am_signed_in_as_an_admin
+ sign_in @team.users.first, role: :admin_staff
end
def and_an_administered_vaccination_record_exists
diff --git a/spec/features/filter_state_persistence_spec.rb b/spec/features/filter_state_persistence_spec.rb
index f714ba21b3..48ee4e1643 100644
--- a/spec/features/filter_state_persistence_spec.rb
+++ b/spec/features/filter_state_persistence_spec.rb
@@ -101,27 +101,34 @@ def and_i_click_the_vaccinated_link
end
def and_i_click_the_no_consent_response_link
- click_on "Review no consent response"
+ click_on "1 child with no response"
end
def and_i_click_the_conflicting_consent_link
- click_on "Review conflicting consent"
+ click_on "1 child with conflicting response"
end
def and_i_click_the_triage_needed_link
- click_on "Review triage needed"
+ click_on "1 child requiring triage"
end
def and_i_click_the_register_attendance_link
- click_on "Review register attendance"
+ click_on "1 child to register"
end
def and_i_click_the_ready_for_vaccinator_link
- click_on "Review ready for vaccinator"
+ click_on "1 child for #{@programme.name}"
end
def then_i_should_be_on_the_record_page
- expect(page).to have_current_path(session_record_path(@session))
+ expect(page).to have_current_path(
+ session_record_path(
+ @session,
+ search_form: {
+ programme_types: [@programme.type]
+ }
+ )
+ )
end
def then_the_vaccinated_filter_is_applied
diff --git a/spec/features/filtering_by_programme_spec.rb b/spec/features/filtering_by_programme_spec.rb
index 5eb8805b7d..ba1d2768b7 100644
--- a/spec/features/filtering_by_programme_spec.rb
+++ b/spec/features/filtering_by_programme_spec.rb
@@ -7,7 +7,7 @@
given_a_session_exists_with_programmes(%i[hpv menacwy])
and_patients_are_in_the_session
- when_i_visit_the_session_outcomes
+ when_i_visit_the_session_patients
then_i_see_all_the_patients
and_i_see_all_the_statuses
@@ -24,7 +24,7 @@
given_a_session_exists_with_programmes([:hpv])
and_patients_are_in_the_session
- when_i_visit_the_session_outcomes
+ when_i_visit_the_session_patients
and_i_filter_on_year_group_eight
the_i_should_only_see_patients_for_year_eight
end
@@ -33,7 +33,7 @@
given_a_session_exists_with_programmes([:hpv])
and_patients_are_in_the_session
- when_i_visit_the_session_outcomes
+ when_i_visit_the_session_patients
then_i_see_all_the_patients
and_i_dont_see_programme_filter_checkboxes
and_i_see_only_hpv_statuses_for_all_patients
@@ -54,9 +54,9 @@ def and_patients_are_in_the_session
create(:patient, year_group: 9, session: @session)
end
- def when_i_visit_the_session_outcomes
+ def when_i_visit_the_session_patients
sign_in @nurse
- visit session_outcome_path(@session)
+ visit session_patients_path(@session)
end
def then_i_see_all_the_patients
@@ -67,8 +67,8 @@ def then_i_see_all_the_patients
end
def and_i_see_all_the_statuses
- expect(page).to have_content("HPVNo outcome yet").twice
- expect(page).to have_content("MenACWYNo outcome yet").once
+ expect(page).to have_content("HPVNo outcome yet").exactly(4).times
+ expect(page).to have_content("MenACWYNo outcome yet").twice
end
def and_i_dont_see_programme_filter_checkboxes
@@ -77,7 +77,7 @@ def and_i_dont_see_programme_filter_checkboxes
end
def and_i_see_only_hpv_statuses_for_all_patients
- expect(page).to have_content("HPVNo outcome yet").twice
+ expect(page).to have_content("HPVNo outcome yet").exactly(4).times
expect(page).not_to have_content("MenACWYNo outcome yet")
end
@@ -87,7 +87,7 @@ def when_i_filter_on_hpv
end
def and_i_see_only_the_hpv_statuses
- expect(page).to have_content("HPVNo outcome yet").twice
+ expect(page).to have_content("HPVNo outcome yet").exactly(4).times
expect(page).not_to have_content("MenACWYNo outcome yet")
end
@@ -106,7 +106,7 @@ def then_i_see_only_patients_eligible_for_menacwy
def and_i_see_only_the_menacwy_statuses
expect(page).not_to have_content("HPVNo outcome yet")
- expect(page).to have_content("MenACWYNo outcome yet").once
+ expect(page).to have_content("MenACWYNo outcome yet").twice
end
def and_i_filter_on_year_group_eight
diff --git a/spec/features/hpv_vaccination_administered_spec.rb b/spec/features/hpv_vaccination_administered_spec.rb
index fb29a37d5d..d210309f85 100644
--- a/spec/features/hpv_vaccination_administered_spec.rb
+++ b/spec/features/hpv_vaccination_administered_spec.rb
@@ -48,6 +48,7 @@
then_i_see_a_success_message
and_i_can_no_longer_vaccinate_the_patient
and_i_no_longer_see_the_patient_in_the_record_tab
+ and_i_no_longer_see_the_patient_in_the_consent_tab
and_the_vaccination_record_is_synced_to_nhs
when_i_go_back
@@ -246,6 +247,11 @@ def and_i_no_longer_see_the_patient_in_the_record_tab
expect(page).to have_content("No children matching search criteria found")
end
+ def and_i_no_longer_see_the_patient_in_the_consent_tab
+ within(".app-secondary-navigation") { click_on "Consent" }
+ expect(page).not_to have_content(@patient.full_name)
+ end
+
def when_i_go_back
visit draft_vaccination_record_path("confirm")
end
diff --git a/spec/features/hpv_vaccination_already_had_spec.rb b/spec/features/hpv_vaccination_already_had_spec.rb
index c99927629c..7069fe4c81 100644
--- a/spec/features/hpv_vaccination_already_had_spec.rb
+++ b/spec/features/hpv_vaccination_already_had_spec.rb
@@ -100,7 +100,7 @@ def and_i_no_longer_see_the_patient_in_the_record_tab
end
def when_i_go_to_the_patient
- click_on "Session outcomes"
+ within(".app-secondary-navigation") { click_on "Children" }
click_on @patient.full_name
end
diff --git a/spec/features/hpv_vaccination_clinic_spec.rb b/spec/features/hpv_vaccination_clinic_spec.rb
index 62df9daa7f..1ea22dfc33 100644
--- a/spec/features/hpv_vaccination_clinic_spec.rb
+++ b/spec/features/hpv_vaccination_clinic_spec.rb
@@ -108,7 +108,7 @@ def and_i_no_longer_see_the_patient_in_the_record_tab
end
def when_i_go_to_the_patient
- click_on "Session outcomes"
+ within(".app-secondary-navigation") { click_on "Children" }
click_on @patient.full_name, match: :first
end
diff --git a/spec/features/hpv_vaccination_delayed_spec.rb b/spec/features/hpv_vaccination_delayed_spec.rb
index 9363fb26c5..ab877248db 100644
--- a/spec/features/hpv_vaccination_delayed_spec.rb
+++ b/spec/features/hpv_vaccination_delayed_spec.rb
@@ -96,7 +96,7 @@ def and_i_can_record_a_second_vaccination
def when_i_go_to_the_outcome_tab
click_on @session.location.name
- click_on "Session outcomes"
+ within(".app-secondary-navigation") { click_on "Children" }
end
def then_i_see_the_patient_has_no_outcome_yet
diff --git a/spec/features/hpv_vaccination_offline_spec.rb b/spec/features/hpv_vaccination_offline_spec.rb
index 947b1bca63..624b8f6869 100644
--- a/spec/features/hpv_vaccination_offline_spec.rb
+++ b/spec/features/hpv_vaccination_offline_spec.rb
@@ -383,16 +383,15 @@ def when_i_navigate_to_the_clinic_page
end
def then_i_see_the_uploaded_vaccination_outcomes_reflected_in_the_session
- click_on "Session outcomes"
-
- choose "Vaccinated"
+ within(".app-secondary-navigation") { click_on "Children" }
+ choose "Vaccinated", match: :first
click_on "Update results"
click_on @vaccinated_patient.full_name
expect(page).to have_content("Vaccinated")
- session_url = current_url
+ patient_url = current_url
click_on "1 February 2024"
expect(page).to have_content("Gardasil 9")
@@ -404,8 +403,8 @@ def then_i_see_the_uploaded_vaccination_outcomes_reflected_in_the_session
)
expect(page).to have_content("SiteLeft arm (upper position)")
- visit session_url
- click_on "Session outcomes"
+ visit patient_url
+ within(".nhsuk-breadcrumb__list") { click_on "Children" }
choose "Absent from session"
click_on "Update results"
@@ -414,9 +413,9 @@ def then_i_see_the_uploaded_vaccination_outcomes_reflected_in_the_session
expect(page).to have_content("No outcome yet")
expect(page).to have_content("Absent from session")
- visit session_url
- click_on "Session outcomes"
- choose "Vaccinated"
+ visit patient_url
+ within(".nhsuk-breadcrumb__list") { click_on "Children" }
+ choose "Vaccinated", match: :first
click_on "Update results"
click_on @restricted_vaccinated_patient.full_name
diff --git a/spec/features/manage_attendance_spec.rb b/spec/features/manage_attendance_spec.rb
index 7336c4efa9..3823193f53 100644
--- a/spec/features/manage_attendance_spec.rb
+++ b/spec/features/manage_attendance_spec.rb
@@ -19,7 +19,7 @@
when_i_register_a_patient_as_absent
then_i_see_the_absent_flash
- when_i_go_to_the_session_outcomes
+ when_i_go_to_the_session_patients
then_i_see_a_patient_is_absent
when_i_go_to_a_patient
@@ -51,7 +51,7 @@
when_i_go_to_the_session
then_i_should_not_see_the_register_tab
- when_i_go_to_the_session_outcomes
+ when_i_go_to_the_session_patients
and_i_go_to_a_patient
then_i_should_not_see_link_to_update_attendance
end
@@ -134,8 +134,8 @@ def when_i_register_a_patient_as_absent
click_button "Absent", match: :first
end
- def when_i_go_to_the_session_outcomes
- click_on "Session outcomes"
+ def when_i_go_to_the_session_patients
+ within(".app-secondary-navigation") { click_on "Children" }
end
def then_i_see_a_patient_is_absent
@@ -146,7 +146,7 @@ def then_i_see_a_patient_is_absent
end
def when_i_go_to_a_patient
- choose "Any"
+ choose "Any", match: :first
click_on "Update results"
click_link PatientSession
diff --git a/spec/features/manage_children_spec.rb b/spec/features/manage_children_spec.rb
index 584acb18e3..bfaca0ca7f 100644
--- a/spec/features/manage_children_spec.rb
+++ b/spec/features/manage_children_spec.rb
@@ -78,6 +78,20 @@
and_the_patient_is_no_longer_invalidated
end
+ scenario "Inviting to community clinic" do
+ given_patients_exist
+
+ when_i_click_on_children
+ and_i_click_on_a_child
+ then_i_see_the_child
+ and_i_dont_see_a_community_clinic_session
+
+ when_i_click_on_invite_to_clinic
+ then_i_see_a_success_banner
+ and_i_see_a_community_clinic_session
+ and_i_dont_see_an_invite_to_clinic_session
+ end
+
scenario "Removing an NHS number" do
given_patients_exist
and_sync_vaccination_records_to_nhs_feature_is_enabled
@@ -140,7 +154,13 @@
def given_my_team_exists
@programme = create(:programme, :hpv)
- @team = create(:team, :with_one_nurse, programmes: [@programme])
+ @team =
+ create(
+ :team,
+ :with_generic_clinic,
+ :with_one_nurse,
+ programmes: [@programme]
+ )
end
def given_patients_exist
@@ -337,6 +357,26 @@ def and_i_see_the_cohort
expect(page).not_to have_content("No sessions")
end
+ def and_i_dont_see_a_community_clinic_session
+ expect(page).not_to have_content("Community clinic")
+ end
+
+ def when_i_click_on_invite_to_clinic
+ click_on "Invite to community clinic"
+ end
+
+ def then_i_see_a_success_banner
+ expect(page).to have_content("invited to the clinic")
+ end
+
+ def and_i_see_a_community_clinic_session
+ expect(page).to have_content("Community clinic")
+ end
+
+ def and_i_dont_see_an_invite_to_clinic_session
+ expect(page).not_to have_button("Invite to community clinic")
+ end
+
def when_i_go_to_the_dashboard
sign_in @team.users.first
diff --git a/spec/features/manage_school_sessions_spec.rb b/spec/features/manage_school_sessions_spec.rb
index e73e1e8217..20709d3cac 100644
--- a/spec/features/manage_school_sessions_spec.rb
+++ b/spec/features/manage_school_sessions_spec.rb
@@ -96,16 +96,13 @@ def given_my_team_is_running_an_hpv_vaccination_programme
@patient =
create(:patient, year_group: 8, session: @session, parents: [@parent])
- @team
- .generic_clinic_session(academic_year: AcademicYear.current)
- .session_dates
- .create!(value: 1.month.from_now.to_date)
-
- create(
- :patient_session,
- patient: @patient,
- session: @team.generic_clinic_session(academic_year: AcademicYear.current)
- )
+ clinic_session =
+ @team.generic_clinic_session(academic_year: AcademicYear.current)
+
+ clinic_session.session_dates.create!(value: 1.month.from_now.to_date)
+
+ clinic_session.set_notification_dates
+ clinic_session.save!
end
def when_i_go_to_todays_sessions_as_a_nurse
@@ -297,11 +294,13 @@ def then_i_see_the_send_invitations_page
:when_i_click_on_send_invitations
def then_i_see_the_invitation_confirmation
- expect(page).to have_content("Clinic invitations sent for 1 child")
+ expect(page).to have_content("1 child invited to the clinic")
end
def and_the_parent_receives_an_invitation
+ EnqueueClinicSessionInvitationsJob.perform_now
perform_enqueued_jobs
+
expect_email_to @parent.email, :session_clinic_initial_invitation
end
end
diff --git a/spec/features/parent_relationships_spec.rb b/spec/features/parent_relationships_spec.rb
index 617ea08a8f..a03c96fdc1 100644
--- a/spec/features/parent_relationships_spec.rb
+++ b/spec/features/parent_relationships_spec.rb
@@ -19,10 +19,11 @@
end
def given_a_patient_with_a_parent_exists
- team = create(:team)
+ programmes = [create(:programme)]
+ team = create(:team, :with_generic_clinic, programmes:)
@nurse = create(:nurse, team:)
- session = create(:session, team:)
+ session = create(:session, team:, programmes:)
@patient = create(:patient, session:)
@parent = create(:parent)
diff --git a/spec/features/parental_consent_clinic_spec.rb b/spec/features/parental_consent_clinic_spec.rb
index f57e02b72c..b9be4e05ab 100644
--- a/spec/features/parental_consent_clinic_spec.rb
+++ b/spec/features/parental_consent_clinic_spec.rb
@@ -275,7 +275,7 @@ def when_the_nurse_checks_the_patient(in_the_school: false)
click_on "Community clinic"
end
- click_on "Session outcome"
+ within(".app-secondary-navigation") { click_on "Children" }
click_on @child.full_name
end
diff --git a/spec/features/parental_consent_create_patient_spec.rb b/spec/features/parental_consent_create_patient_spec.rb
index f84dddca80..510940aff6 100644
--- a/spec/features/parental_consent_create_patient_spec.rb
+++ b/spec/features/parental_consent_create_patient_spec.rb
@@ -203,7 +203,7 @@ def when_they_check_triage
end
click_link "Pilot School"
- click_on "Session outcomes"
+ within(".app-secondary-navigation") { click_on "Children" }
end
def then_the_patient_should_be_ready_to_vaccinate
diff --git a/spec/features/parental_consent_manual_matching_spec.rb b/spec/features/parental_consent_manual_matching_spec.rb
index 815c0b45cd..5a0ebb33ed 100644
--- a/spec/features/parental_consent_manual_matching_spec.rb
+++ b/spec/features/parental_consent_manual_matching_spec.rb
@@ -63,7 +63,7 @@
def given_the_app_is_setup
programmes = [create(:programme, :hpv)]
- @team = create(:team, :with_one_nurse, programmes:)
+ @team = create(:team, :with_generic_clinic, :with_one_nurse, programmes:)
@user = @team.users.first
@school = create(:school, name: "Pilot School", team: @team)
@session = create(:session, location: @school, team: @team, programmes:)
diff --git a/spec/features/parental_consent_refused_spec.rb b/spec/features/parental_consent_refused_spec.rb
index e3566a7d6c..a6c1a6fd5d 100644
--- a/spec/features/parental_consent_refused_spec.rb
+++ b/spec/features/parental_consent_refused_spec.rb
@@ -147,7 +147,7 @@ def then_they_see_that_the_child_has_consent_refused
end
def and_the_session_outcome_is_could_not_vaccinate
- click_on "Session outcomes"
+ within(".app-secondary-navigation") { click_on "Children" }
choose "Refused vaccine"
click_on "Update results"
expect(page).to have_content(@child.full_name)
@@ -157,9 +157,7 @@ def and_the_programme_outcome_is_could_not_vaccinate
click_on "Programmes", match: :first
click_on "HPV", match: :first
- within ".app-secondary-navigation" do
- click_on "Children"
- end
+ within(".app-secondary-navigation") { click_on "Children" }
choose "Could not vaccinate"
click_on "Update results"
diff --git a/spec/features/parental_consent_send_request_spec.rb b/spec/features/parental_consent_send_request_spec.rb
index fe5125b44a..94088c93df 100644
--- a/spec/features/parental_consent_send_request_spec.rb
+++ b/spec/features/parental_consent_send_request_spec.rb
@@ -38,6 +38,8 @@ def given_a_patient_without_consent_exists
@parent = create(:parent)
@patient = create(:patient, session: @session, parents: [@parent])
+
+ StatusUpdater.call
end
def and_i_am_signed_in
diff --git a/spec/features/parental_consent_spec.rb b/spec/features/parental_consent_spec.rb
index 0f6c8fd9c6..e2840bdff7 100644
--- a/spec/features/parental_consent_spec.rb
+++ b/spec/features/parental_consent_spec.rb
@@ -191,8 +191,8 @@ def and_they_see_the_full_consent_form
def when_they_check_triage
click_on @session.location.name
- click_on "Session outcomes"
- choose "No outcome yet"
+ within(".app-secondary-navigation") { click_on "Children" }
+ choose "No outcome yet", match: :first
click_on "Update results"
end
diff --git a/spec/features/patient_invalidation_spec.rb b/spec/features/patient_invalidation_spec.rb
new file mode 100644
index 0000000000..b2e70a211b
--- /dev/null
+++ b/spec/features/patient_invalidation_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+describe "Patient invalidation deletes vaccination record from API" do
+ around { |example| travel_to(Date.new(2025, 8, 7)) { example.run } }
+
+ scenario "PDS check invalidates patient and deletes vaccination record from API" do
+ given_a_patient_has_a_vaccination_record_eligible_for_api
+ and_the_feature_flags_are_enabled
+ and_the_vaccination_record_has_been_sent_to_the_api
+
+ when_a_pds_check_is_completed_which_returns_that_the_patient_is_invalid
+
+ then_the_patient_has_been_invalidated
+ and_the_vaccination_record_is_deleted_from_the_api
+ end
+
+ def given_a_patient_has_a_vaccination_record_eligible_for_api
+ @programme = create(:programme, :hpv)
+ @team = create(:team, :with_one_nurse, programmes: [@programme])
+ @session =
+ create(:session, :scheduled, team: @team, programmes: [@programme])
+ @patient = create(:patient, session: @session, nhs_number: "9000000009")
+
+ @vaccination_record =
+ create(
+ :vaccination_record,
+ :administered,
+ patient: @patient,
+ programme: @programme,
+ session: @session,
+ notify_parents: true
+ )
+ end
+
+ def and_the_feature_flags_are_enabled
+ Flipper.enable(:enqueue_sync_vaccination_records_to_nhs)
+ Flipper.enable(:immunisations_fhir_api_integration)
+ end
+
+ def and_the_vaccination_record_has_been_sent_to_the_api
+ @immunisation_uuid = Random.uuid
+
+ @stubbed_post_request =
+ stub_immunisations_api_post(uuid: @immunisation_uuid)
+
+ @vaccination_record.sync_to_nhs_immunisations_api
+ perform_enqueued_jobs(only: SyncVaccinationRecordToNHSJob)
+
+ expect(@stubbed_post_request).to have_been_requested
+
+ @vaccination_record.reload
+ expect(@vaccination_record.nhs_immunisations_api_id).to be_present
+ expect(@vaccination_record.nhs_immunisations_api_synced_at).to be_present
+ end
+
+ def when_a_pds_check_is_completed_which_returns_that_the_patient_is_invalid
+ # Move time forward to ensure deletion sync happens after the initial sync
+ travel_to(1.hour.from_now)
+
+ stub_pds_get_nhs_number_to_return_an_invalidated_patient
+
+ PatientUpdateFromPDSJob.perform_now(@patient)
+ end
+
+ def then_the_patient_has_been_invalidated
+ @patient.reload
+ expect(@patient).to be_invalidated
+ expect(@patient.invalidated_at).to be_present
+ end
+
+ def and_the_vaccination_record_is_deleted_from_the_api
+ @stubbed_delete_request =
+ stub_immunisations_api_delete(uuid: @immunisation_uuid)
+
+ perform_enqueued_jobs(only: SyncVaccinationRecordToNHSJob)
+
+ expect(@stubbed_delete_request).to have_been_requested
+ end
+end
diff --git a/spec/features/patient_search_spec.rb b/spec/features/patient_search_spec.rb
index 9b0276a455..f969a6ee05 100644
--- a/spec/features/patient_search_spec.rb
+++ b/spec/features/patient_search_spec.rb
@@ -53,7 +53,7 @@
and_i_search_for_a_name_that_doesnt_exist
then_i_see_no_results
- when_i_visit_the_session_outcome_tab
+ when_i_visit_the_session_patients_tab
and_i_search_for_a_name_that_doesnt_exist
then_i_see_no_results
end
@@ -217,8 +217,8 @@ def when_i_visit_the_session_record_tab
visit session_record_path(@session)
end
- def when_i_visit_the_session_outcome_tab
- visit session_outcome_path(@session)
+ def when_i_visit_the_session_patients_tab
+ visit session_patients_path(@session)
end
def and_i_search_for_a_name_that_doesnt_exist
diff --git a/spec/features/td_ipv_already_had_spec.rb b/spec/features/td_ipv_already_had_spec.rb
index 2dc49352c5..22b771b229 100644
--- a/spec/features/td_ipv_already_had_spec.rb
+++ b/spec/features/td_ipv_already_had_spec.rb
@@ -18,6 +18,7 @@
when_i_click_record_as_already_vaccinated
and_i_confirm_the_details
then_i_see_the_patient_is_already_vaccinated
+ and_the_patient_no_longer_appears_in_consent
and_the_consent_requests_are_sent
then_the_parent_doesnt_receive_a_consent_request
end
@@ -35,6 +36,7 @@
when_i_click_record_as_already_vaccinated
and_i_confirm_the_details
then_i_see_the_patient_is_already_vaccinated
+ and_the_patient_no_longer_appears_in_consent
and_the_consent_requests_are_sent
then_the_parent_doesnt_receive_a_consent_request
end
@@ -52,6 +54,7 @@
when_i_click_record_as_already_vaccinated
and_i_confirm_the_details
then_i_see_the_patient_is_already_vaccinated
+ and_the_patient_no_longer_appears_in_consent
and_i_click_on_triage
then_i_see_the_patient_doesnt_need_triage
end
@@ -201,6 +204,12 @@ def then_i_see_the_patient_is_already_vaccinated
expect(page).to have_content("LocationUnknown")
end
+ def and_the_patient_no_longer_appears_in_consent
+ within(".nhsuk-breadcrumb__list") { click_on "Children" }
+ within(".app-secondary-navigation") { click_on "Consent" }
+ expect(page).not_to have_content(@patient.full_name)
+ end
+
def and_i_cannot_record_the_patient_as_already_vaccinated
expect(page).not_to have_content("Record as already vaccinated")
end
diff --git a/spec/features/triage_delay_vaccination_spec.rb b/spec/features/triage_delay_vaccination_spec.rb
index 777ba4f792..e3d7d25f5d 100644
--- a/spec/features/triage_delay_vaccination_spec.rb
+++ b/spec/features/triage_delay_vaccination_spec.rb
@@ -87,8 +87,8 @@ def then_i_see_the_patient
def when_i_access_the_vaccinate_later_page
click_on @school.name, match: :first
- click_on "Session outcomes"
- choose "No outcome yet"
+ within(".app-secondary-navigation") { click_on "Children" }
+ choose "No outcome yet", match: :first
click_on "Update results"
end
diff --git a/spec/features/user_authorisation_spec.rb b/spec/features/user_authorisation_spec.rb
index a256216ead..ba7fb3e5f8 100644
--- a/spec/features/user_authorisation_spec.rb
+++ b/spec/features/user_authorisation_spec.rb
@@ -58,11 +58,9 @@ def and_i_go_to_the_consent_page
visit "/dashboard"
click_on "Programmes", match: :first
click_on "HPV", match: :first
- within ".app-secondary-navigation" do
- click_on "Sessions"
- end
+ within(".app-secondary-navigation") { click_on "Sessions" }
click_on "Pilot School"
- click_on "Consent"
+ within(".app-secondary-navigation") { click_on "Children" }
end
def then_i_should_only_see_my_patients
@@ -70,10 +68,6 @@ def then_i_should_only_see_my_patients
expect(page).not_to have_content(@other_child.full_name)
end
- def when_i_go_to_the_consent_page_of_another_team
- visit "/sessions/#{@other_session.id}/consent"
- end
-
def then_i_should_see_page_not_found
expect(page).to have_content("Page not found")
end
diff --git a/spec/features/vaccination_programmes_spec.rb b/spec/features/vaccination_programmes_spec.rb
new file mode 100644
index 0000000000..892c48d6da
--- /dev/null
+++ b/spec/features/vaccination_programmes_spec.rb
@@ -0,0 +1,221 @@
+# frozen_string_literal: true
+
+describe "Vaccination programmes table" do
+ around { |example| travel_to(Date.new(2025, 8, 31)) { example.run } }
+
+ before { given_my_team_exists }
+
+ scenario "patient has non-seasonal vaccine" do
+ given_patients_exist_in_year_eleven
+ and_the_patient_is_vaccinated_for_hpv
+
+ when_i_click_on_children
+ and_i_click_on_a_child
+
+ then_the_table_has_a_row_showing_hpv_vaccinated
+ and_the_table_shows_other_eligible_vaccinations
+ end
+
+ scenario "patient has non-seasonal vaccine with more than one dose" do
+ given_patients_exist_in_year_eleven
+ and_the_patient_is_vaccinated_for_hpv
+ and_the_patient_has_a_second_dose_of_hpv
+
+ when_i_click_on_children
+ and_i_click_on_a_child
+
+ then_the_table_has_a_row_showing_hpv_vaccinated
+ and_the_table_has_a_row_showing_second_hpv_vaccinated
+ end
+
+ scenario "patient has a seasonal vaccine" do
+ given_patients_exist_in_year_eleven
+ and_the_patient_had_two_flu_doses_last_year
+
+ when_i_click_on_children
+ and_i_click_on_a_child
+
+ then_the_table_has_two_rows_showing_flu_vaccinated
+ end
+
+ scenario "patient has an outcome other than vaccinated" do
+ given_patients_exist_in_year_eleven
+ and_the_patient_has_an_outcome_other_than_vaccinated
+
+ when_i_click_on_children
+ and_i_click_on_a_child
+
+ then_the_table_displays_the_outcome
+ end
+
+ def given_my_team_exists
+ @programmes = [
+ @flu_programme = create(:programme, :flu),
+ @hpv_programme = create(:programme, :hpv),
+ @menacwy_programme = create(:programme, :menacwy),
+ @td_ipv_programme = create(:programme, :td_ipv)
+ ]
+
+ @team =
+ create(
+ :team,
+ :with_one_nurse,
+ :with_generic_clinic,
+ programmes: @programmes
+ )
+ end
+
+ def given_patients_exist_in_year_eleven
+ school = create(:school, team: @team)
+
+ @session =
+ create(:session, location: school, team: @team, programmes: @programmes)
+
+ @patient =
+ create(
+ :patient,
+ session: @session,
+ year_group: 10,
+ given_name: "John",
+ family_name: "Smith",
+ programmes: @programmes,
+ school:
+ )
+ end
+
+ def when_i_click_on_children
+ sign_in @team.users.first
+
+ visit "/dashboard"
+ click_on "Children", match: :first
+ end
+
+ def and_i_click_on_a_child
+ click_on "SMITH, John"
+ end
+
+ def then_the_table_has_a_row_showing_hpv_vaccinated
+ expect(page).to have_selector(
+ "table.nhsuk-table tbody tr",
+ text: "HPV"
+ ) do |row|
+ expect(row).to have_selector("td.nhsuk-table__cell", text: "Vaccinated")
+ end
+ end
+
+ def and_the_table_has_a_row_showing_second_hpv_vaccinated
+ expect(page).to have_selector(
+ "table.nhsuk-table tbody tr",
+ text: "HPV (2nd dose)"
+ ) do |row|
+ expect(row).to have_selector("td.nhsuk-table__cell", text: "Vaccinated")
+ end
+ end
+
+ def and_the_table_shows_other_eligible_vaccinations
+ expect(page).to have_selector(
+ "table.nhsuk-table tbody tr",
+ text: "Flu (Winter 2025)"
+ ) do |row|
+ expect(row).to have_selector(
+ "td.nhsuk-table__cell",
+ text: "Eligibility starts 1 September 2025"
+ )
+ end
+
+ expect(page).to have_selector(
+ "table.nhsuk-table tbody tr",
+ text: "MenACWY"
+ ) do |row|
+ expect(row).to have_selector(
+ "td.nhsuk-table__cell",
+ text: "Selected for the Year 2023 to 2024 MenACWY cohort"
+ )
+ end
+ end
+
+ def then_the_table_has_two_rows_showing_flu_vaccinated
+ expect(page).to have_selector(
+ "table.nhsuk-table tbody tr",
+ text: "Flu (Winter 2024)"
+ ) do |row|
+ expect(row).to have_selector(
+ "td.nhsuk-table__cell",
+ text: "Vaccinated 1 September 2024"
+ )
+ end
+
+ expect(page).to have_selector(
+ "table.nhsuk-table tbody tr",
+ text: "Flu (Winter 2024, 2nd dose)"
+ ) do |row|
+ expect(row).to have_selector(
+ "td.nhsuk-table__cell",
+ text: "Vaccinated 1 March 2025"
+ )
+ end
+ end
+
+ def then_the_table_displays_the_outcome
+ expect(page).to have_selector(
+ "table.nhsuk-table tbody tr",
+ text: "HPV"
+ ) do |row|
+ expect(row).to have_selector(
+ "td.nhsuk-table__cell",
+ text: "Could not vaccinate"
+ )
+ expect(row).to have_selector("td.nhsuk-table__cell", text: "Not well")
+ end
+ end
+
+ def and_the_patient_is_vaccinated_for_hpv
+ create(
+ :vaccination_record,
+ patient: @patient,
+ programme: @hpv_programme,
+ session: @session,
+ performed_at: 6.months.ago
+ )
+ end
+
+ def and_the_patient_has_a_second_dose_of_hpv
+ create(
+ :vaccination_record,
+ dose_sequence: 2,
+ patient: @patient,
+ programme: @hpv_programme,
+ session: @session
+ )
+ end
+
+ def and_the_patient_had_two_flu_doses_last_year
+ create(
+ :vaccination_record,
+ patient: @patient,
+ programme: @flu_programme,
+ session: @session,
+ performed_at: Time.zone.local(2024, 9, 1)
+ )
+ create(
+ :vaccination_record,
+ dose_sequence: 2,
+ patient: @patient,
+ programme: @flu_programme,
+ session: @session,
+ performed_at: Time.zone.local(2025, 3, 1)
+ )
+ StatusUpdater.call(patient: @patient)
+ end
+
+ def and_the_patient_has_an_outcome_other_than_vaccinated
+ create(
+ :vaccination_record,
+ :not_administered,
+ patient: @patient,
+ programme: @hpv_programme,
+ session: @session
+ )
+ StatusUpdater.call(patient: @patient)
+ end
+end
diff --git a/spec/features/verbal_consent_but_no_triage_for_admin_spec.rb b/spec/features/verbal_consent_but_no_triage_for_admin_spec.rb
index cc4d8ee569..8d64d9c96a 100644
--- a/spec/features/verbal_consent_but_no_triage_for_admin_spec.rb
+++ b/spec/features/verbal_consent_but_no_triage_for_admin_spec.rb
@@ -18,6 +18,8 @@ def given_i_am_signed_in_as_an_admin
@parent = create(:parent)
@patient = create(:patient, session: @session, parents: [@parent])
+ StatusUpdater.call
+
sign_in team.users.first, role: :admin_staff
end
diff --git a/spec/features/verbal_consent_change_answers_spec.rb b/spec/features/verbal_consent_change_answers_spec.rb
index 66a1b8febd..e5c7aab435 100644
--- a/spec/features/verbal_consent_change_answers_spec.rb
+++ b/spec/features/verbal_consent_change_answers_spec.rb
@@ -37,6 +37,8 @@ def given_a_patient_is_in_an_hpv_programme
@parent_relationship =
create(:parent_relationship, :mother, patient: @patient, parent: @parent)
+
+ StatusUpdater.call
end
def when_i_get_consent_for_the_patient
diff --git a/spec/features/verbal_consent_given_by_new_parental_contact_spec.rb b/spec/features/verbal_consent_given_by_new_parental_contact_spec.rb
index 7fc75fb87e..e2f7f310c1 100644
--- a/spec/features/verbal_consent_given_by_new_parental_contact_spec.rb
+++ b/spec/features/verbal_consent_given_by_new_parental_contact_spec.rb
@@ -31,6 +31,8 @@ def given_i_am_signed_in
@session = create(:session, team:, programmes:)
@patient = create(:patient, session: @session)
+ StatusUpdater.call
+
sign_in team.users.first
end
diff --git a/spec/features/verbal_consent_given_do_not_vaccinate_spec.rb b/spec/features/verbal_consent_given_do_not_vaccinate_spec.rb
index ee402de9bd..5314cfedc3 100644
--- a/spec/features/verbal_consent_given_do_not_vaccinate_spec.rb
+++ b/spec/features/verbal_consent_given_do_not_vaccinate_spec.rb
@@ -18,6 +18,8 @@ def given_i_am_signed_in
@parent = create(:parent)
@patient = create(:patient, session: @session, parents: [@parent])
+ StatusUpdater.call
+
sign_in team.users.first
end
diff --git a/spec/features/verbal_consent_given_keep_in_triage_spec.rb b/spec/features/verbal_consent_given_keep_in_triage_spec.rb
index 7e59449214..fef62a15b6 100644
--- a/spec/features/verbal_consent_given_keep_in_triage_spec.rb
+++ b/spec/features/verbal_consent_given_keep_in_triage_spec.rb
@@ -18,6 +18,8 @@ def given_i_am_signed_in
@parent = create(:parent)
@patient = create(:patient, session: @session, parents: [@parent])
+ StatusUpdater.call
+
sign_in team.users.first
end
diff --git a/spec/features/verbal_consent_given_safe_to_vaccinate_spec.rb b/spec/features/verbal_consent_given_safe_to_vaccinate_spec.rb
index 06c51f8bd2..dc77753ed8 100644
--- a/spec/features/verbal_consent_given_safe_to_vaccinate_spec.rb
+++ b/spec/features/verbal_consent_given_safe_to_vaccinate_spec.rb
@@ -21,6 +21,8 @@ def given_i_am_signed_in
@parent = create(:parent)
@patient = create(:patient, session: @session, parents: [@parent])
+ StatusUpdater.call
+
sign_in team.users.first
end
diff --git a/spec/features/verbal_consent_given_spec.rb b/spec/features/verbal_consent_given_spec.rb
index c572b3895c..39b808b8f1 100644
--- a/spec/features/verbal_consent_given_spec.rb
+++ b/spec/features/verbal_consent_given_spec.rb
@@ -75,6 +75,8 @@ def create_programme(programme_type)
@parent = create(:parent)
@patient = create(:patient, session: @session, parents: [@parent])
+
+ StatusUpdater.call
end
def when_i_record_that_verbal_consent_was_given
diff --git a/spec/features/verbal_consent_refused_personal_choice_spec.rb b/spec/features/verbal_consent_refused_personal_choice_spec.rb
index c5d894f89d..7b540bd0c7 100644
--- a/spec/features/verbal_consent_refused_personal_choice_spec.rb
+++ b/spec/features/verbal_consent_refused_personal_choice_spec.rb
@@ -27,6 +27,8 @@ def given_i_am_signed_in
@parent = create(:parent)
@patient = create(:patient, session: @session, parents: [@parent])
+ StatusUpdater.call
+
sign_in team.users.first
end
diff --git a/spec/features/verbal_consent_refused_spec.rb b/spec/features/verbal_consent_refused_spec.rb
index 261bafc667..082df7d5b8 100644
--- a/spec/features/verbal_consent_refused_spec.rb
+++ b/spec/features/verbal_consent_refused_spec.rb
@@ -46,6 +46,8 @@ def and_i_am_signed_in
@parent = create(:parent)
@patient = create(:patient, session: @session, parents: [@parent])
+ StatusUpdater.call
+
sign_in team.users.first
end
diff --git a/spec/fixtures/files/onboarding/valid.yaml b/spec/fixtures/files/onboarding/valid.yaml
index 22f614def8..59ba198e9c 100644
--- a/spec/fixtures/files/onboarding/valid.yaml
+++ b/spec/fixtures/files/onboarding/valid.yaml
@@ -9,6 +9,7 @@ team:
careplus_venue_code: EXAMPLE
privacy_notice_url: https://example.com/privacy-notice
privacy_policy_url: https://example.com/privacy-policy
+ workgroup: nhstrust
programmes: [hpv]
diff --git a/spec/helpers/patients_helper_spec.rb b/spec/helpers/patients_helper_spec.rb
index a3e9b3a3a3..bc0a84d86c 100644
--- a/spec/helpers/patients_helper_spec.rb
+++ b/spec/helpers/patients_helper_spec.rb
@@ -121,179 +121,4 @@
end
end
end
-
- describe "patient_important_notices" do
- subject(:notifications) do
- helper.patient_important_notices(patient_with_preloaded_associations)
- end
-
- let(:patient) { create(:patient) }
- let(:patient_with_preloaded_associations) do
- Patient.includes(vaccination_records: :programme).find(patient.id)
- end
- let(:programme) { create(:programme, :hpv) }
-
- context "when patient has no special status" do
- it "returns empty array" do
- expect(notifications).to eq([])
- end
- end
-
- context "when patient is deceased" do
- let(:recorded_at) { Date.new(2025, 2, 1) }
-
- before do
- patient.update!(
- date_of_death: Date.new(2025, 1, 1),
- date_of_death_recorded_at: recorded_at
- )
- end
-
- it "returns deceased notification" do
- expect(notifications.count).to eq(1)
- expect(notifications.first).to include(
- date_time: recorded_at,
- message: "Record updated with child’s date of death"
- )
- end
- end
-
- context "when patient is invalidated" do
- let(:invalidated_at) { Date.new(2025, 1, 1) }
-
- before { patient.update!(invalidated_at:) }
-
- it "returns invalidated notification" do
- expect(notifications.count).to eq(1)
- expect(notifications.first).to include(
- date_time: invalidated_at,
- message: "Record flagged as invalid"
- )
- end
- end
-
- context "when patient is restricted" do
- let(:restricted_at) { Date.new(2025, 1, 1) }
-
- before { patient.update!(restricted_at:) }
-
- it "returns restricted notification" do
- expect(notifications.count).to eq(1)
- expect(notifications.first).to include(
- date_time: restricted_at,
- message: "Record flagged as sensitive"
- )
- end
- end
-
- context "when patient has gillick no notify vaccination records" do
- let(:performed_at) { Date.new(2025, 1, 1) }
-
- let(:vaccination_record) do
- create(
- :vaccination_record,
- patient: patient,
- programme: programme,
- notify_parents: false,
- performed_at:
- )
- end
-
- before { vaccination_record }
-
- it "returns gillick no notify notification" do
- expect(notifications.count).to eq(1)
- notification = notifications.first
- expect(notification[:date_time]).to eq(performed_at)
- expect(notification[:message]).to include(
- "Child gave consent for HPV vaccination under Gillick competence"
- )
- expect(notification[:message]).to include(
- "does not want their parents to be notified"
- )
- end
- end
-
- context "when patient has multiple vaccination records with the same notify_parents values" do
- let(:other_programme) { create(:programme, :flu) }
-
- let(:notify_record) do
- create(
- :vaccination_record,
- patient:,
- programme: other_programme,
- notify_parents: false
- )
- end
- let(:no_notify_record) do
- create(:vaccination_record, patient:, programme:, notify_parents: false)
- end
-
- before do
- notify_record
- no_notify_record
- end
-
- it "only includes records with notify_parents false in the message" do
- expect(notifications.count).to eq(1)
- expect(notifications.first[:message]).to include(
- "Flu and HPV vaccinations"
- )
- end
- end
-
- context "when patient has multiple vaccination records with different notify_parents values" do
- let(:other_programme) { create(:programme, :flu) }
-
- let(:notify_record) do
- create(
- :vaccination_record,
- patient:,
- programme: other_programme,
- notify_parents: true
- )
- end
- let(:no_notify_record) do
- create(:vaccination_record, patient:, programme:, notify_parents: false)
- end
-
- before do
- notify_record
- no_notify_record
- end
-
- it "only includes records with notify_parents false in the message" do
- expect(notifications.count).to eq(1)
- expect(notifications.first[:message]).to include("HPV vaccination")
- end
- end
-
- context "when patient has multiple notification types" do
- let(:deceased_at) { Date.new(2025, 1, 3) }
- let(:restricted_at) { Date.new(2025, 1, 2) }
- let(:invalidated_at) { Date.new(2025, 1, 1) }
-
- before do
- patient.update!(
- date_of_death: Date.current,
- date_of_death_recorded_at: deceased_at,
- restricted_at: restricted_at,
- invalidated_at: invalidated_at
- )
- end
-
- it "returns all notifications sorted by date_time descending" do
- expect(notifications.count).to eq(3)
-
- # Should be sorted by date_time in reverse order (most recent first)
- expect(notifications[0][:date_time]).to eq(deceased_at)
- expect(notifications[1][:date_time]).to eq(restricted_at)
- expect(notifications[2][:date_time]).to eq(invalidated_at)
-
- expect(notifications[0][:message]).to include("date of death")
- expect(notifications[1][:message]).to include("flagged as sensitive")
- expect(notifications[2][:message]).to include("flagged as invalid")
- end
- end
- end
end
diff --git a/spec/jobs/enqueue_clinic_session_invitations_job_spec.rb b/spec/jobs/enqueue_clinic_session_invitations_job_spec.rb
index dac27e1b0d..d2e1dfe138 100644
--- a/spec/jobs/enqueue_clinic_session_invitations_job_spec.rb
+++ b/spec/jobs/enqueue_clinic_session_invitations_job_spec.rb
@@ -16,110 +16,10 @@
let(:session) { create(:session, programmes:, date:, location:, team:) }
let(:patient_session) { create(:patient_session, patient:, session:) }
- it "sends a notification" do
- expect(SessionNotification).to receive(:create_and_send!).once.with(
- patient_session:,
- session_date: date,
- type: :clinic_initial_invitation
- )
- perform_now
- end
-
- context "when patient goes to a school" do
- let(:patient) { create(:patient, parents:, school: create(:school)) }
-
- it "doesn't send any notifications" do
- expect(SessionNotification).not_to receive(:create_and_send!)
- perform_now
- end
- end
-
- context "when already sent for that date" do
- before do
- create(
- :session_notification,
- :clinic_initial_invitation,
- session:,
- patient:
- )
- end
-
- it "doesn't send any notifications" do
- expect(SessionNotification).not_to receive(:create_and_send!)
- perform_now
- end
-
- context "with a second date a week later" do
- before { session.session_dates.create!(value: date + 1.week) }
-
- let(:today) { date + 1.day }
-
- it "doesn't send any notifications" do
- expect(SessionNotification).not_to receive(:create_and_send!)
- perform_now
- end
- end
- end
-
- context "when already vaccinated" do
- before do
- create(
- :vaccination_record,
- patient:,
- session:,
- programme: programmes.first,
- location_name: "A clinic."
- )
- end
-
- it "doesn't send any notifications" do
- expect(SessionNotification).not_to receive(:create_and_send!)
- perform_now
- end
- end
-
- context "when refused consent has been received" do
- before do
- create(
- :consent,
- :refused,
- patient:,
- programme: programmes.first,
- parent: parents.first
- )
- end
-
- it "doesn't send any notifications" do
- expect(SessionNotification).not_to receive(:create_and_send!)
- perform_now
- end
- end
-
- context "if the patient is deceased" do
- let(:patient) { create(:patient, :deceased, parents:) }
-
- it "doesn't send any notifications" do
- expect(SessionNotification).not_to receive(:create_and_send!)
- perform_now
- end
- end
-
- context "if the patient is invalid" do
- let(:patient) { create(:patient, :invalidated, parents:) }
-
- it "doesn't send any notifications" do
- expect(SessionNotification).not_to receive(:create_and_send!)
- perform_now
- end
- end
-
- context "if the patient is restricted" do
- let(:patient) { create(:patient, :restricted, parents:) }
-
- it "doesn't send any notifications" do
- expect(SessionNotification).not_to receive(:create_and_send!)
- perform_now
- end
+ it "queues a job for the session" do
+ expect { perform_now }.to have_enqueued_job(
+ SendClinicInitialInvitationsJob
+ ).with(session)
end
end
@@ -128,13 +28,10 @@
let(:session) { create(:session, programmes:, date:, location:, team:) }
let(:patient_session) { create(:patient_session, patient:, session:) }
- it "sends a notification" do
- expect(SessionNotification).to receive(:create_and_send!).once.with(
- patient_session:,
- session_date: date,
- type: :clinic_initial_invitation
- )
- perform_now
+ it "queues a job for the session" do
+ expect { perform_now }.to have_enqueued_job(
+ SendClinicInitialInvitationsJob
+ ).with(session)
end
end
@@ -143,9 +40,10 @@
let(:session) { create(:session, programmes:, date:, location:, team:) }
let(:patient_session) { create(:patient_session, patient:, session:) }
- it "doesn't send any notifications" do
- expect(SessionNotification).not_to receive(:create_and_send!)
- perform_now
+ it "doesn't queue any jobs" do
+ expect { perform_now }.not_to have_enqueued_job(
+ SendClinicInitialInvitationsJob
+ )
end
end
@@ -163,9 +61,10 @@
)
end
- it "doesn't send any notifications" do
- expect(SessionNotification).not_to receive(:create_and_send!)
- perform_now
+ it "doesn't queue any jobs" do
+ expect { perform_now }.not_to have_enqueued_job(
+ SendClinicInitialInvitationsJob
+ )
end
end
@@ -181,9 +80,10 @@
)
end
- it "doesn't send any notifications" do
- expect(SessionNotification).not_to receive(:create_and_send!)
- perform_now
+ it "doesn't queue any jobs" do
+ expect { perform_now }.not_to have_enqueued_job(
+ SendClinicInitialInvitationsJob
+ )
end
end
end
diff --git a/spec/jobs/send_clinic_initial_invitations_job_spec.rb b/spec/jobs/send_clinic_initial_invitations_job_spec.rb
index 9b30deee8f..c3cfa55d9e 100644
--- a/spec/jobs/send_clinic_initial_invitations_job_spec.rb
+++ b/spec/jobs/send_clinic_initial_invitations_job_spec.rb
@@ -1,16 +1,14 @@
# frozen_string_literal: true
describe SendClinicInitialInvitationsJob do
- subject(:perform_now) do
- described_class.perform_now(session, school: nil, programmes:)
- end
+ subject(:perform_now) { described_class.perform_now(session) }
+ let(:today) { Date.new(2025, 7, 1) }
let(:programmes) { [create(:programme, :hpv)] }
let(:team) { create(:team, programmes:) }
let(:parents) { create_list(:parent, 2) }
let(:patient) { create(:patient, parents:, year_group: 9) }
let(:location) { create(:generic_clinic, team:) }
-
let(:session) do
create(
:session,
@@ -22,6 +20,8 @@
end
let!(:patient_session) { create(:patient_session, patient:, session:) }
+ around { |example| travel_to(today) { example.run } }
+
it "sends a notification" do
expect(SessionNotification).to receive(:create_and_send!).once.with(
patient_session:,
diff --git a/spec/jobs/send_school_consent_requests_job_spec.rb b/spec/jobs/send_school_consent_requests_job_spec.rb
index 13d2cf59b9..554e976448 100644
--- a/spec/jobs/send_school_consent_requests_job_spec.rb
+++ b/spec/jobs/send_school_consent_requests_job_spec.rb
@@ -3,10 +3,9 @@
describe SendSchoolConsentRequestsJob do
subject(:perform_now) { described_class.perform_now(session) }
+ let(:today) { Date.new(2025, 7, 1) }
let(:programmes) { [create(:programme)] }
-
let(:parents) { create_list(:parent, 2) }
-
let(:patient_with_request_sent) do
create(:patient, :consent_request_sent, programmes:)
end
@@ -17,7 +16,6 @@
let(:deceased_patient) { create(:patient, :deceased) }
let(:invalid_patient) { create(:patient, :invalidated) }
let(:restricted_patient) { create(:patient, :restricted) }
-
let!(:patients) do
[
patient_with_request_sent,
@@ -29,6 +27,8 @@
]
end
+ around { |example| travel_to(today) { example.run } }
+
context "when session is unscheduled" do
let(:session) { create(:session, :unscheduled, patients:, programmes:) }
diff --git a/spec/lib/generate/cohort_imports_spec.rb b/spec/lib/generate/cohort_imports_spec.rb
index 100948834d..1993e79f62 100644
--- a/spec/lib/generate/cohort_imports_spec.rb
+++ b/spec/lib/generate/cohort_imports_spec.rb
@@ -1,15 +1,20 @@
# frozen_string_literal: true
describe Generate::CohortImports do
+ subject(:cohort_imports) do
+ described_class.new(team:, programmes: [programme])
+ end
+
+ let(:programme) { create(:programme, :hpv) }
+ let(:team) { create(:team, programmes: [programme]) }
+
before do
- team = create(:team, ods_code: "A9A5A")
- programme = create(:programme, :hpv)
location =
create(:school, :secondary, team:, name: "Test School", urn: "31337")
create(:session, team:, slug: "slug", location:, programmes: [programme])
end
it "generates patients" do
- expect(described_class.new.patients.count).to eq 10
+ expect(cohort_imports.patients.count).to eq 10
end
end
diff --git a/spec/lib/important_notices_spec.rb b/spec/lib/important_notices_spec.rb
new file mode 100644
index 0000000000..bc976ff6c9
--- /dev/null
+++ b/spec/lib/important_notices_spec.rb
@@ -0,0 +1,187 @@
+# frozen_string_literal: true
+
+describe ImportantNotices do
+ shared_examples "generates notices" do
+ context "when patient has no special status" do
+ it { should be_empty }
+ end
+
+ context "when patient is deceased" do
+ let(:recorded_at) { Date.new(2025, 2, 1) }
+
+ before do
+ patient.update!(
+ date_of_death: Date.new(2025, 1, 1),
+ date_of_death_recorded_at: recorded_at
+ )
+ end
+
+ it "returns deceased notification" do
+ expect(notices.count).to eq(1)
+ expect(notices.first).to include(
+ date_time: recorded_at,
+ message: "Record updated with child’s date of death"
+ )
+ end
+ end
+
+ context "when patient is invalidated" do
+ let(:invalidated_at) { Date.new(2025, 1, 1) }
+
+ before { patient.update!(invalidated_at:) }
+
+ it "returns invalidated notification" do
+ expect(notices.count).to eq(1)
+ expect(notices.first).to include(
+ date_time: invalidated_at,
+ message: "Record flagged as invalid"
+ )
+ end
+ end
+
+ context "when patient is restricted" do
+ let(:restricted_at) { Date.new(2025, 1, 1) }
+
+ before { patient.update!(restricted_at:) }
+
+ it "returns restricted notification" do
+ expect(notices.count).to eq(1)
+ expect(notices.first).to include(
+ date_time: restricted_at,
+ message: "Record flagged as sensitive"
+ )
+ end
+ end
+
+ context "when patient has gillick no notify vaccination records" do
+ let(:performed_at) { Date.new(2025, 1, 1) }
+
+ let(:vaccination_record) do
+ create(
+ :vaccination_record,
+ patient: patient,
+ programme: programme,
+ notify_parents: false,
+ performed_at:
+ )
+ end
+
+ before { vaccination_record }
+
+ it "returns gillick no notify notification" do
+ expect(notices.count).to eq(1)
+ notification = notices.first
+ expect(notification[:date_time]).to eq(performed_at)
+ expect(notification[:message]).to include(
+ "Child gave consent for HPV vaccination under Gillick competence"
+ )
+ expect(notification[:message]).to include(
+ "does not want their parents to be notified"
+ )
+ end
+ end
+
+ context "when patient has multiple vaccination records with the same notify_parents values" do
+ let(:other_programme) { create(:programme, :flu) }
+
+ let(:notify_record) do
+ create(
+ :vaccination_record,
+ patient:,
+ programme: other_programme,
+ notify_parents: false
+ )
+ end
+ let(:no_notify_record) do
+ create(:vaccination_record, patient:, programme:, notify_parents: false)
+ end
+
+ before do
+ notify_record
+ no_notify_record
+ end
+
+ it "only includes records with notify_parents false in the message" do
+ expect(notices.count).to eq(1)
+ expect(notices.first[:message]).to include("Flu and HPV vaccinations")
+ end
+ end
+
+ context "when patient has multiple vaccination records with different notify_parents values" do
+ let(:other_programme) { create(:programme, :flu) }
+
+ let(:notify_record) do
+ create(
+ :vaccination_record,
+ patient:,
+ programme: other_programme,
+ notify_parents: true
+ )
+ end
+ let(:no_notify_record) do
+ create(:vaccination_record, patient:, programme:, notify_parents: false)
+ end
+
+ before do
+ notify_record
+ no_notify_record
+ end
+
+ it "only includes records with notify_parents false in the message" do
+ expect(notices.count).to eq(1)
+ expect(notices.first[:message]).to include("HPV vaccination")
+ end
+ end
+
+ context "when patient has multiple notification types" do
+ let(:deceased_at) { Date.new(2025, 1, 3) }
+ let(:restricted_at) { Date.new(2025, 1, 2) }
+ let(:invalidated_at) { Date.new(2025, 1, 1) }
+
+ before do
+ patient.update!(
+ date_of_death: Date.current,
+ date_of_death_recorded_at: deceased_at,
+ restricted_at: restricted_at,
+ invalidated_at: invalidated_at
+ )
+ end
+
+ it "returns all notices sorted by date_time descending" do
+ expect(notices.count).to eq(3)
+
+ # Should be sorted by date_time in reverse order (most recent first)
+ expect(notices[0][:date_time]).to eq(deceased_at)
+ expect(notices[1][:date_time]).to eq(restricted_at)
+ expect(notices[2][:date_time]).to eq(invalidated_at)
+
+ expect(notices[0][:message]).to include("date of death")
+ expect(notices[1][:message]).to include("flagged as sensitive")
+ expect(notices[2][:message]).to include("flagged as invalid")
+ end
+ end
+ end
+
+ let(:patient) { create(:patient) }
+ let(:programme) { create(:programme, :hpv) }
+
+ context "with a patient scope" do
+ subject(:notices) { described_class.call(patient_scope:) }
+
+ let(:patient_scope) { Patient.where(id: patient.id) }
+
+ include_examples "generates notices"
+ end
+
+ context "with a single patient" do
+ subject(:notices) do
+ described_class.call(patient: patient_with_preloaded_associations)
+ end
+
+ let(:patient_with_preloaded_associations) do
+ Patient.includes(vaccination_records: :programme).find(patient.id)
+ end
+
+ include_examples "generates notices"
+ end
+end
diff --git a/spec/lib/location_sessions_factory_spec.rb b/spec/lib/location_sessions_factory_spec.rb
index 70f49b68dc..637a198beb 100644
--- a/spec/lib/location_sessions_factory_spec.rb
+++ b/spec/lib/location_sessions_factory_spec.rb
@@ -120,7 +120,7 @@
session =
team.sessions.includes(:patients).find_by(location:, academic_year:)
expect(session.patients).to include(patient_at_location)
- expect(session.patients).to include(patient_at_school_location)
+ expect(session.patients).not_to include(patient_at_school_location)
end
end
end
diff --git a/spec/lib/nhs/immunisations_api_spec.rb b/spec/lib/nhs/immunisations_api_spec.rb
index 4fda7ddadd..93d7449a9c 100644
--- a/spec/lib/nhs/immunisations_api_spec.rb
+++ b/spec/lib/nhs/immunisations_api_spec.rb
@@ -551,6 +551,12 @@
it { should be false }
end
+
+ context "when the patient is invalidated" do
+ before { patient.update(invalidated_at: Time.current) }
+
+ it { should be false }
+ end
end
describe "next_sync_action" do
diff --git a/spec/lib/status_generator/consent_spec.rb b/spec/lib/status_generator/consent_spec.rb
new file mode 100644
index 0000000000..bfe106d243
--- /dev/null
+++ b/spec/lib/status_generator/consent_spec.rb
@@ -0,0 +1,409 @@
+# frozen_string_literal: true
+
+describe StatusGenerator::Consent do
+ subject(:generator) do
+ described_class.new(
+ programme:,
+ academic_year: AcademicYear.current,
+ patient:,
+ consents: patient.consents,
+ vaccination_records: patient.vaccination_records
+ )
+ end
+
+ let(:patient) { create(:patient) }
+ let(:programme) { create(:programme) }
+
+ describe "#status" do
+ subject { generator.status }
+
+ context "with no consent" do
+ it { should be(:no_response) }
+ end
+
+ context "with an invalidated consent" do
+ before { create(:consent, :invalidated, patient:, programme:) }
+
+ it { should be(:no_response) }
+ end
+
+ context "with a not provided consent" do
+ before { create(:consent, :not_provided, patient:, programme:) }
+
+ it { should be(:no_response) }
+ end
+
+ context "with both an invalidated and not provided consent" do
+ before do
+ create(:consent, :invalidated, patient:, programme:)
+ create(:consent, :not_provided, patient:, programme:)
+ end
+
+ it { should be(:no_response) }
+ end
+
+ context "with a refused consent" do
+ before { create(:consent, :refused, patient:, programme:) }
+
+ it { should be(:refused) }
+ end
+
+ context "with a given consent" do
+ before { create(:consent, :given, patient:, programme:) }
+
+ it { should be(:given) }
+ end
+
+ context "with conflicting consent" do
+ before do
+ create(:consent, :given, patient:, programme:)
+ create(
+ :consent,
+ :refused,
+ patient:,
+ programme:,
+ parent: create(:parent)
+ )
+ end
+
+ it { should be(:conflicts) }
+ end
+
+ context "with two given consents with different methods" do
+ before do
+ create(
+ :consent,
+ :given,
+ patient:,
+ programme:,
+ vaccine_methods: %w[injection]
+ )
+ create(
+ :consent,
+ :given,
+ patient:,
+ programme:,
+ vaccine_methods: %w[nasal],
+ parent: create(:parent)
+ )
+ end
+
+ it { should be(:conflicts) }
+ end
+
+ context "with two given consents, one both and one with injection only" do
+ before do
+ create(
+ :consent,
+ :given,
+ patient:,
+ programme:,
+ vaccine_methods: %w[injection]
+ )
+ create(
+ :consent,
+ :given,
+ patient:,
+ programme:,
+ vaccine_methods: %w[nasal injection],
+ parent: create(:parent)
+ )
+ end
+
+ it { should be(:given) }
+ end
+
+ context "with an invalidated refused and given consent" do
+ before do
+ create(:consent, :refused, :invalidated, patient:, programme:)
+ create(:consent, :given, patient:, programme:)
+ end
+
+ it { should be(:given) }
+ end
+
+ context "with a refused and given consent from the same parent at different times" do
+ before do
+ create(
+ :consent,
+ :refused,
+ patient:,
+ programme:,
+ created_at: 1.day.ago,
+ submitted_at: 2.days.ago
+ )
+ create(
+ :consent,
+ :given,
+ patient:,
+ programme:,
+ created_at: 2.days.ago,
+ submitted_at: 1.day.ago
+ )
+ end
+
+ it { should be(:given) }
+ end
+
+ context "with self-consent" do
+ before { create(:consent, :self_consent, :given, patient:, programme:) }
+
+ it { should be(:given) }
+
+ context "and refused parental consent" do
+ before { create(:consent, :refused, patient:, programme:) }
+
+ it { should be(:given) }
+ end
+
+ context "and conflicting parental consent" do
+ before do
+ create(:consent, :refused, patient:, programme:)
+ create(
+ :consent,
+ :given,
+ patient:,
+ programme:,
+ parent: create(:parent)
+ )
+ end
+
+ it { should be(:given) }
+ end
+ end
+
+ describe "academic year filtering" do
+ let(:current_academic_year) { AcademicYear.current }
+ let(:previous_academic_year) { current_academic_year - 1 }
+ let(:patient) { create(:patient) }
+ let(:programme) { create(:programme) }
+ let(:parent) { create(:parent) }
+
+ context "with a given consent from the current academic year" do
+ before do
+ create(
+ :consent,
+ :given,
+ patient: patient,
+ programme: programme,
+ parent: parent,
+ submitted_at: Date.new(current_academic_year, 10, 15).in_time_zone
+ )
+ end
+
+ it { should be(:given) }
+ end
+
+ context "with a given consent from a previous academic year" do
+ before do
+ create(
+ :consent,
+ :given,
+ patient: patient,
+ programme: programme,
+ parent: parent,
+ submitted_at: Date.new(previous_academic_year, 10, 15).in_time_zone
+ )
+ end
+
+ it { should be(:no_response) }
+ end
+
+ context "with a given and refused consent from current and previous academic years" do
+ before do
+ create(
+ :consent,
+ :given,
+ patient: patient,
+ programme: programme,
+ parent: parent,
+ submitted_at: Date.new(current_academic_year, 10, 15).in_time_zone
+ )
+ create(
+ :consent,
+ :refused,
+ patient: patient,
+ programme: programme,
+ parent: create(:parent),
+ submitted_at: Date.new(previous_academic_year, 10, 15).in_time_zone
+ )
+ end
+
+ it { should be(:given) }
+ end
+
+ context "with a refused and given consent from the current and previous academic years" do
+ before do
+ create(
+ :consent,
+ :refused,
+ patient: patient,
+ programme: programme,
+ parent: parent,
+ submitted_at: Date.new(current_academic_year, 10, 15).in_time_zone
+ )
+ create(
+ :consent,
+ :given,
+ patient: patient,
+ programme: programme,
+ parent: create(:parent),
+ submitted_at: Date.new(previous_academic_year, 10, 15).in_time_zone
+ )
+ end
+
+ it { should be(:refused) }
+ end
+ end
+ end
+
+ describe "#vaccine_methods" do
+ subject { generator.vaccine_methods }
+
+ context "with no consent" do
+ it { should be_empty }
+ end
+
+ context "with an invalidated consent" do
+ before { create(:consent, :invalidated, patient:, programme:) }
+
+ it { should be_empty }
+ end
+
+ context "with a not provided consent" do
+ before { create(:consent, :not_provided, patient:, programme:) }
+
+ it { should be_empty }
+ end
+
+ context "with both an invalidated and not provided consent" do
+ before do
+ create(:consent, :invalidated, patient:, programme:)
+ create(:consent, :not_provided, patient:, programme:)
+ end
+
+ it { should be_empty }
+ end
+
+ context "with a refused consent" do
+ before { create(:consent, :refused, patient:, programme:) }
+
+ it { should be_empty }
+ end
+
+ context "with an injection given consent" do
+ before do
+ create(
+ :consent,
+ :given,
+ patient:,
+ programme:,
+ vaccine_methods: %w[injection]
+ )
+ end
+
+ it { should contain_exactly("injection") }
+ end
+
+ context "with a nasal given consent" do
+ before do
+ create(
+ :consent,
+ :given,
+ patient:,
+ programme:,
+ vaccine_methods: %w[nasal]
+ )
+ end
+
+ it { should contain_exactly("nasal") }
+ end
+
+ context "with both nasal and injection given consent" do
+ before do
+ create(
+ :consent,
+ :given,
+ patient:,
+ programme:,
+ vaccine_methods: %w[nasal injection]
+ )
+ end
+
+ it { should eq(%w[nasal injection]) }
+ end
+
+ context "with one parent nasal and one parent both" do
+ before do
+ create(
+ :consent,
+ :given,
+ patient:,
+ programme:,
+ vaccine_methods: %w[nasal]
+ )
+ create(
+ :consent,
+ :given,
+ patient:,
+ programme:,
+ parent: create(:parent),
+ vaccine_methods: %w[nasal injection]
+ )
+ end
+
+ it { should contain_exactly("nasal") }
+ end
+
+ context "with conflicting consent" do
+ before do
+ create(:consent, :given, patient:, programme:)
+ create(
+ :consent,
+ :refused,
+ patient:,
+ programme:,
+ parent: create(:parent)
+ )
+ end
+
+ it { should be_empty }
+ end
+
+ context "with an invalidated refused and given consent" do
+ before do
+ create(:consent, :refused, :invalidated, patient:, programme:)
+ create(:consent, :given, patient:, programme:)
+ end
+
+ it { should contain_exactly("injection") }
+ end
+
+ context "with self-consent" do
+ before { create(:consent, :self_consent, :given, patient:, programme:) }
+
+ it { should contain_exactly("injection") }
+
+ context "and refused parental consent" do
+ before { create(:consent, :refused, patient:, programme:) }
+
+ it { should contain_exactly("injection") }
+ end
+
+ context "and conflicting parental consent" do
+ before do
+ create(:consent, :refused, patient:, programme:)
+ create(
+ :consent,
+ :given,
+ patient:,
+ programme:,
+ parent: create(:parent)
+ )
+ end
+
+ it { should contain_exactly("injection") }
+ end
+ end
+ end
+end
diff --git a/spec/lib/status_generator/registration_spec.rb b/spec/lib/status_generator/registration_spec.rb
new file mode 100644
index 0000000000..a21a52516e
--- /dev/null
+++ b/spec/lib/status_generator/registration_spec.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+describe StatusGenerator::Registration do
+ subject(:generator) do
+ described_class.new(
+ patient_session:,
+ session_attendance:
+ patient_session.session_attendances.find_by(
+ session_date: session.session_dates.last
+ ),
+ vaccination_records: patient.vaccination_records
+ )
+ end
+
+ let(:programmes) do
+ [create(:programme, :menacwy), create(:programme, :td_ipv)]
+ end
+ let(:patient) { create(:patient, year_group: 9) }
+ let(:session) do
+ create(:session, dates: [Date.yesterday, Date.current], programmes:)
+ end
+ let(:patient_session) { create(:patient_session, patient:, session:) }
+
+ describe "#status" do
+ subject { generator.status }
+
+ context "with no session attendance" do
+ it { should be(:unknown) }
+ end
+
+ context "with a session attendance for a different day to today" do
+ before do
+ create(
+ :session_attendance,
+ :present,
+ patient_session:,
+ session_date: session.session_dates.first
+ )
+ end
+
+ it { should be(:unknown) }
+ end
+
+ context "with a present session attendance for today" do
+ before do
+ create(
+ :session_attendance,
+ :present,
+ patient_session:,
+ session_date: session.session_dates.second
+ )
+ end
+
+ it { should be(:attending) }
+ end
+
+ context "with an absent session attendance for today" do
+ before do
+ create(
+ :session_attendance,
+ :absent,
+ patient_session:,
+ session_date: session.session_dates.second
+ )
+ end
+
+ it { should be(:not_attending) }
+ end
+
+ context "with an outcome for one of the programmes" do
+ before do
+ create(
+ :vaccination_record,
+ patient:,
+ session:,
+ programme: programmes.first
+ )
+ end
+
+ it { should be(:unknown) }
+ end
+
+ context "with an outcome for both of the programmes" do
+ before do
+ programmes.each do |programme|
+ create(:vaccination_record, patient:, session:, programme:)
+ end
+ end
+
+ it { should be(:completed) }
+ end
+ end
+end
diff --git a/spec/lib/status_generator/session_spec.rb b/spec/lib/status_generator/session_spec.rb
new file mode 100644
index 0000000000..6426660c01
--- /dev/null
+++ b/spec/lib/status_generator/session_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+describe StatusGenerator::Session do
+ subject(:generator) do
+ described_class.new(
+ session_id: patient_session.session_id,
+ academic_year: patient_session.academic_year,
+ session_attendance: patient_session.session_attendances.last,
+ programme_id: programme.id,
+ consents: patient.consents,
+ triages: patient.triages,
+ vaccination_records: patient.vaccination_records
+ )
+ end
+
+ let(:patient_session) { create(:patient_session, programmes: [programme]) }
+ let(:programme) { create(:programme) }
+
+ describe "#status" do
+ subject(:status) { generator.status }
+
+ let(:patient) { patient_session.patient }
+ let(:session) { patient_session.session }
+
+ context "with no vaccination record" do
+ it { should be(:none_yet) }
+ end
+
+ context "with a vaccination administered" do
+ before { create(:vaccination_record, patient:, session:, programme:) }
+
+ it { should be(:vaccinated) }
+ end
+
+ context "with a vaccination not administered" do
+ before do
+ create(
+ :vaccination_record,
+ :not_administered,
+ patient:,
+ session:,
+ programme:
+ )
+ end
+
+ it { should be(:unwell) }
+ end
+
+ context "with a discarded vaccination administered" do
+ before do
+ create(:vaccination_record, :discarded, patient:, session:, programme:)
+ end
+
+ it { should be(:none_yet) }
+ end
+
+ context "with a consent refused" do
+ before { create(:consent, :refused, patient:, programme:) }
+
+ it { should be(:refused) }
+ end
+
+ context "with conflicting consent" do
+ before do
+ create(:consent, :refused, patient:, programme:)
+
+ parent = create(:parent_relationship, patient:).parent
+ create(:consent, :given, patient:, programme:, parent:)
+ end
+
+ it { should be(:none_yet) }
+ end
+
+ context "when triaged as do not vaccinate" do
+ before { create(:triage, :do_not_vaccinate, patient:, programme:) }
+
+ it { should be(:had_contraindications) }
+ end
+
+ context "when not attending the session" do
+ before { create(:session_attendance, :absent, patient_session:) }
+
+ it { should be(:absent_from_session) }
+ end
+ end
+end
diff --git a/spec/lib/status_generator/triage_spec.rb b/spec/lib/status_generator/triage_spec.rb
new file mode 100644
index 0000000000..2247cc69e5
--- /dev/null
+++ b/spec/lib/status_generator/triage_spec.rb
@@ -0,0 +1,315 @@
+# frozen_string_literal: true
+
+describe StatusGenerator::Triage do
+ subject(:generator) do
+ described_class.new(
+ programme:,
+ academic_year: AcademicYear.current,
+ patient:,
+ consents: patient.consents,
+ triages: patient.triages,
+ vaccination_records: patient.vaccination_records
+ )
+ end
+
+ let(:patient) { create(:patient) }
+ let(:programme) { create(:programme, :hpv) }
+
+ describe "#status" do
+ subject { generator.status }
+
+ context "with no triage" do
+ it { should be(:not_required) }
+ end
+
+ context "with conflicting consent" do
+ before do
+ create(:consent, :given, patient:, programme:)
+ create(
+ :consent,
+ :refused,
+ :needing_triage,
+ patient:,
+ programme:,
+ parent: create(:parent)
+ )
+ end
+
+ it { should be(:not_required) }
+ end
+
+ context "with two given consents with different methods" do
+ before do
+ create(
+ :consent,
+ :given,
+ :needing_triage,
+ patient:,
+ programme:,
+ vaccine_methods: %w[injection]
+ )
+ create(
+ :consent,
+ :given,
+ :needing_triage,
+ patient:,
+ programme:,
+ vaccine_methods: %w[nasal],
+ parent: create(:parent)
+ )
+ end
+
+ it { should be(:not_required) }
+ end
+
+ context "with a consent that needs triage" do
+ before { create(:consent, :needing_triage, patient:, programme:) }
+
+ it { should be(:required) }
+ end
+
+ context "with a historical vaccination that needs triage" do
+ let(:programme) { create(:programme, :td_ipv) }
+
+ before do
+ create(:vaccination_record, patient:, programme:, dose_sequence: 1)
+ end
+
+ it { should be(:not_required) }
+
+ context "when consent is given" do
+ before { create(:consent, :given, patient:, programme:) }
+
+ it { should be(:required) }
+ end
+
+ context "when consent is refused" do
+ before { create(:consent, :refused, patient:, programme:) }
+
+ it { should be(:not_required) }
+ end
+ end
+
+ context "with a safe to vaccinate triage" do
+ before { create(:triage, :ready_to_vaccinate, patient:, programme:) }
+
+ it { should be(:safe_to_vaccinate) }
+ end
+
+ context "with a do not vaccinate triage" do
+ before { create(:triage, :do_not_vaccinate, patient:, programme:) }
+
+ it { should be(:do_not_vaccinate) }
+ end
+
+ context "with a needs follow up triage" do
+ before { create(:triage, :needs_follow_up, patient:, programme:) }
+
+ it { should be(:required) }
+ end
+
+ context "with a delay vaccination triage" do
+ before { create(:triage, :delay_vaccination, patient:, programme:) }
+
+ it { should be(:delay_vaccination) }
+ end
+
+ context "with an invalidated safe to vaccinate triage" do
+ before do
+ create(:triage, :ready_to_vaccinate, :invalidated, patient:, programme:)
+ end
+
+ it { should be(:not_required) }
+ end
+
+ context "when the patient is already vaccinated" do
+ shared_examples "a vaccinated patient with any triage status" do
+ before do
+ create(:triage, triage_trait, patient:, programme:) if triage_trait
+ end
+
+ it { should be(:not_required) }
+ end
+
+ before { create(:vaccination_record, patient:, programme:) }
+
+ context "with a safe to vaccinate triage" do
+ it_behaves_like "a vaccinated patient with any triage status" do
+ let(:triage_trait) { :ready_to_vaccinate }
+ end
+ end
+
+ context "with a do not vaccinate triage" do
+ it_behaves_like "a vaccinated patient with any triage status" do
+ let(:triage_trait) { :do_not_vaccinate }
+ end
+ end
+
+ context "with a needs follow up triage" do
+ it_behaves_like "a vaccinated patient with any triage status" do
+ let(:triage_trait) { :needs_follow_up }
+ end
+ end
+
+ context "with a delay vaccination triage" do
+ it_behaves_like "a vaccinated patient with any triage status" do
+ let(:triage_trait) { :delay_vaccination }
+ end
+ end
+ end
+
+ describe "academic year filtering" do
+ let(:current_academic_year) { Date.current.academic_year }
+ let(:previous_academic_year) { current_academic_year - 1 }
+ let(:patient) { create(:patient) }
+ let(:programme) { create(:programme) }
+
+ context "with a ready to vaccinate triage from the current academic year" do
+ before do
+ create(
+ :triage,
+ :ready_to_vaccinate,
+ patient: patient,
+ programme: programme,
+ created_at: Date.new(current_academic_year, 10, 15).in_time_zone
+ )
+ end
+
+ it { should be(:safe_to_vaccinate) }
+ end
+
+ context "with a ready to vaccinate triage from a previous academic year" do
+ before do
+ create(
+ :triage,
+ :ready_to_vaccinate,
+ patient: patient,
+ programme: programme,
+ created_at: Date.new(previous_academic_year, 10, 15).in_time_zone
+ )
+ end
+
+ it { should be(:not_required) }
+ end
+
+ context "with a ready to vaccinate and a do not vaccinate triage from the current and previous academic years" do
+ before do
+ create(
+ :triage,
+ :ready_to_vaccinate,
+ patient: patient,
+ programme: programme,
+ created_at: Date.new(current_academic_year, 10, 15).in_time_zone
+ )
+ create(
+ :triage,
+ :do_not_vaccinate,
+ patient: patient,
+ programme: programme,
+ created_at: Date.new(previous_academic_year, 10, 15).in_time_zone
+ )
+ end
+
+ it { should be(:safe_to_vaccinate) }
+ end
+
+ context "with a do not vaccinate and ready to vaccinate triage from the current and previous academic years" do
+ before do
+ create(
+ :triage,
+ :do_not_vaccinate,
+ patient: patient,
+ programme: programme,
+ created_at: Date.new(current_academic_year, 10, 15).in_time_zone
+ )
+ create(
+ :triage,
+ :ready_to_vaccinate,
+ patient: patient,
+ programme: programme,
+ created_at: Date.new(previous_academic_year, 10, 15).in_time_zone
+ )
+ end
+
+ it { should be(:do_not_vaccinate) }
+ end
+ end
+ end
+
+ describe "#vaccine_method" do
+ subject { generator.vaccine_method }
+
+ context "with no triage" do
+ it { should be_nil }
+ end
+
+ context "with a consent that needs triage" do
+ before { create(:consent, :needing_triage, patient:, programme:) }
+
+ it { should be_nil }
+ end
+
+ context "with a historical vaccination that needs triage" do
+ let(:programme) { create(:programme, :td_ipv) }
+
+ before do
+ create(:vaccination_record, patient:, programme:, dose_sequence: 1)
+ end
+
+ it { should be_nil }
+
+ context "when consent is given" do
+ before { create(:consent, :given, patient:, programme:) }
+
+ it { should be_nil }
+ end
+
+ context "when consent is refused" do
+ before { create(:consent, :refused, patient:, programme:) }
+
+ it { should be_nil }
+ end
+ end
+
+ context "with a safe to vaccinate triage" do
+ before { create(:triage, :ready_to_vaccinate, patient:, programme:) }
+
+ it { should eq("injection") }
+ end
+
+ context "with a safe to vaccinate triage and vaccinated" do
+ before do
+ create(:triage, :ready_to_vaccinate, patient:, programme:)
+ create(:vaccination_record, patient:, programme:)
+ end
+
+ it { should be_nil }
+ end
+
+ context "with a do not vaccinate triage" do
+ before { create(:triage, :do_not_vaccinate, patient:, programme:) }
+
+ it { should be_nil }
+ end
+
+ context "with a needs follow up triage" do
+ before { create(:triage, :needs_follow_up, patient:, programme:) }
+
+ it { should be_nil }
+ end
+
+ context "with a delay vaccination triage" do
+ before { create(:triage, :delay_vaccination, patient:, programme:) }
+
+ it { should be_nil }
+ end
+
+ context "with an invalidated safe to vaccinate triage" do
+ before do
+ create(:triage, :ready_to_vaccinate, :invalidated, patient:, programme:)
+ end
+
+ it { should be_nil }
+ end
+ end
+end
diff --git a/spec/lib/status_generator/vaccination_spec.rb b/spec/lib/status_generator/vaccination_spec.rb
new file mode 100644
index 0000000000..089b52ac50
--- /dev/null
+++ b/spec/lib/status_generator/vaccination_spec.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+describe StatusGenerator::Vaccination do
+ subject(:generator) do
+ described_class.new(
+ programme:,
+ academic_year: AcademicYear.current,
+ patient:,
+ consents: patient.consents,
+ triages: patient.triages,
+ vaccination_records: patient.vaccination_records
+ )
+ end
+
+ let(:patient) { create(:patient) }
+ let(:programme) { create(:programme, :hpv) }
+
+ describe "#status" do
+ subject { generator.status }
+
+ context "with no vaccination record" do
+ it { should be(:none_yet) }
+ end
+
+ context "with a vaccination administered" do
+ before { create(:vaccination_record, patient:, programme:) }
+
+ it { should be(:vaccinated) }
+ end
+
+ context "with a vaccination already had" do
+ before do
+ create(
+ :vaccination_record,
+ :not_administered,
+ :already_had,
+ patient:,
+ programme:
+ )
+ end
+
+ it { should be(:vaccinated) }
+ end
+
+ context "with a vaccination not administered" do
+ before do
+ create(:vaccination_record, :not_administered, patient:, programme:)
+ end
+
+ it { should be(:none_yet) }
+ end
+
+ context "with a consent refused" do
+ before { create(:consent, :refused, patient:, programme:) }
+
+ it { should be(:could_not_vaccinate) }
+ end
+
+ context "with a triage as unsafe to vaccination" do
+ before { create(:triage, :do_not_vaccinate, patient:, programme:) }
+
+ it { should be(:could_not_vaccinate) }
+ end
+
+ context "with a discarded vaccination administered" do
+ before { create(:vaccination_record, :discarded, patient:, programme:) }
+
+ it { should be(:none_yet) }
+ end
+ end
+end
diff --git a/spec/models/class_import_spec.rb b/spec/models/class_import_spec.rb
index 2c3425273f..c2224aaa84 100644
--- a/spec/models/class_import_spec.rb
+++ b/spec/models/class_import_spec.rb
@@ -348,16 +348,62 @@
end
end
- context "with an existing patient in a different school" do
+ context "with an existing patient in a session for a previous academic year but not the current" do
+ let(:previous_academic_year) { session.academic_year - 1 }
+
let(:patient) do
create(
:patient,
nhs_number: "9990000018",
- school: create(:school),
+ school: location,
+ session:
+ create(
+ :session,
+ team:,
+ programmes:,
+ location:,
+ academic_year: previous_academic_year,
+ date: previous_academic_year.to_academic_year_date_range.begin
+ )
+ )
+ end
+
+ it "adds the patient to the upcoming session" do
+ expect(patient.sessions).not_to include(session)
+
+ expect { process! }.to change { patient.reload.sessions.count }.by(1)
+
+ expect(patient.sessions).to include(session)
+ end
+ end
+
+ context "with an existing patient in the same school but not in the team" do
+ let(:patient) do
+ create(
+ :patient,
+ nhs_number: "9990000018",
+ school: location,
session: create(:session, programmes:)
)
end
+ it "adds the patient to the session" do
+ expect(patient.sessions).not_to include(session)
+ process!
+ expect(patient.reload.sessions).to include(session)
+ end
+ end
+
+ context "with an existing patient already in the team but in a different school" do
+ let(:patient) do
+ create(
+ :patient,
+ nhs_number: "9990000018",
+ school: create(:school),
+ session: create(:session, team:, programmes:)
+ )
+ end
+
it "proposes a school move for the child" do
expect(patient.school_moves).to be_empty
diff --git a/spec/models/cohort_import_spec.rb b/spec/models/cohort_import_spec.rb
index f699c4330f..77505d7af3 100644
--- a/spec/models/cohort_import_spec.rb
+++ b/spec/models/cohort_import_spec.rb
@@ -395,15 +395,5 @@
expect { process! }.to change(session.patients, :count).from(0).to(2)
end
end
-
- context "with a scheduled clinic session" do
- let(:session) do
- team.generic_clinic_session(academic_year: AcademicYear.current)
- end
-
- it "adds all the patients to the session" do
- expect { process! }.to change(session.patients, :count).from(0).to(3)
- end
- end
end
end
diff --git a/spec/models/concerns/editable_wrapper_spec.rb b/spec/models/concerns/editable_wrapper_spec.rb
index 7134be9c6a..132da6a255 100644
--- a/spec/models/concerns/editable_wrapper_spec.rb
+++ b/spec/models/concerns/editable_wrapper_spec.rb
@@ -12,7 +12,7 @@
def save!(context:)
end
- def reset!
+ def clear!
end
end
end
diff --git a/spec/models/concerns/request_session_persistable_spec.rb b/spec/models/concerns/request_session_persistable_spec.rb
index c0080d9d12..27aafbc5db 100644
--- a/spec/models/concerns/request_session_persistable_spec.rb
+++ b/spec/models/concerns/request_session_persistable_spec.rb
@@ -12,7 +12,7 @@
def request_session_key = "key"
- def reset_unused_fields
+ def reset_unused_attributes
end
end
end
@@ -121,13 +121,25 @@ def reset_unused_fields
end
end
- describe "#reset!" do
- subject(:reset!) { model.reset! }
+ describe "#clear_attributes" do
+ subject(:clear_attributes) { model.clear_attributes }
+
+ let(:attributes) { { string: "abc" } }
+
+ it "resets all the attributes and doesn't save to the session" do
+ expect { clear_attributes }.to change(model, :attributes).to(
+ { "datetime" => nil, "string" => nil }
+ ).and(not_change { request_session })
+ end
+ end
+
+ describe "#clear!" do
+ subject(:clear!) { model.clear! }
let(:attributes) { { string: "abc" } }
it "resets all the attributes and saves to the session" do
- expect { reset! }.to change(model, :attributes).to(
+ expect { clear! }.to change(model, :attributes).to(
{ "datetime" => nil, "string" => nil }
).and change { request_session }.to(
{ "key" => { "datetime" => nil, "string" => nil } }
diff --git a/spec/models/draft_consent_spec.rb b/spec/models/draft_consent_spec.rb
index fdfb097eb8..151577c342 100644
--- a/spec/models/draft_consent_spec.rb
+++ b/spec/models/draft_consent_spec.rb
@@ -115,7 +115,7 @@
end
end
- describe "#reset_unused_fields" do
+ describe "#reset_unused_attributes" do
subject(:save!) { draft_consent.save! }
context "when given" do
diff --git a/spec/models/draft_vaccination_record_spec.rb b/spec/models/draft_vaccination_record_spec.rb
index 5d85500b95..dc67d4c4b8 100644
--- a/spec/models/draft_vaccination_record_spec.rb
+++ b/spec/models/draft_vaccination_record_spec.rb
@@ -242,7 +242,7 @@
end
end
- describe "#reset_unused_fields" do
+ describe "#reset_unused_attributes" do
subject(:save!) { draft_vaccination_record.save! }
context "when administered" do
diff --git a/spec/models/onboarding_spec.rb b/spec/models/onboarding_spec.rb
index 38b3a8378e..323be5a7ba 100644
--- a/spec/models/onboarding_spec.rb
+++ b/spec/models/onboarding_spec.rb
@@ -85,6 +85,7 @@
"team.phone": ["can't be blank", "is invalid"],
"team.privacy_notice_url": ["can't be blank"],
"team.privacy_policy_url": ["can't be blank"],
+ "team.workgroup": ["can't be blank"],
"school.0.subteam": ["can't be blank"],
"school.1.subteam": ["can't be blank"],
"school.2.status": ["is not included in the list"],
diff --git a/spec/models/parent_spec.rb b/spec/models/parent_spec.rb
index f3103d1eac..bc3d2ed54c 100644
--- a/spec/models/parent_spec.rb
+++ b/spec/models/parent_spec.rb
@@ -290,7 +290,7 @@
end
end
- describe "#reset_unused_fields" do
+ describe "#reset_unused_attributes" do
it "resets contact method fields when phone number is removed" do
subject =
build(:parent, :contact_method_other, phone_receive_updates: false)
diff --git a/spec/models/patient/consent_status_spec.rb b/spec/models/patient/consent_status_spec.rb
index 7ce1754b85..d5b87efeb9 100644
--- a/spec/models/patient/consent_status_spec.rb
+++ b/spec/models/patient/consent_status_spec.rb
@@ -27,7 +27,7 @@
end
let(:patient) { create(:patient) }
- let(:programme) { create(:programme) }
+ let(:programme) { create(:programme, :hpv) }
before { patient.strict_loading!(false) }
@@ -36,27 +36,41 @@
it do
expect(patient_consent_status).to define_enum_for(:status).with_values(
- %i[no_response given refused conflicts]
+ %i[no_response given refused conflicts not_required]
)
end
describe "#status" do
subject { patient_consent_status.tap(&:assign_status).status.to_sym }
+ shared_examples "when vaccinated" do
+ context "when vaccinated" do
+ before { create(:vaccination_record, patient:, programme:) }
+
+ it { should be(:not_required) }
+ end
+ end
+
context "with no consent" do
it { should be(:no_response) }
+
+ include_examples "when vaccinated"
end
context "with an invalidated consent" do
before { create(:consent, :invalidated, patient:, programme:) }
it { should be(:no_response) }
+
+ include_examples "when vaccinated"
end
context "with a not provided consent" do
before { create(:consent, :not_provided, patient:, programme:) }
it { should be(:no_response) }
+
+ include_examples "when vaccinated"
end
context "with both an invalidated and not provided consent" do
@@ -66,18 +80,24 @@
end
it { should be(:no_response) }
+
+ include_examples "when vaccinated"
end
context "with a refused consent" do
before { create(:consent, :refused, patient:, programme:) }
it { should be(:refused) }
+
+ include_examples "when vaccinated"
end
context "with a given consent" do
before { create(:consent, :given, patient:, programme:) }
it { should be(:given) }
+
+ include_examples "when vaccinated"
end
context "with conflicting consent" do
@@ -93,6 +113,8 @@
end
it { should be(:conflicts) }
+
+ include_examples "when vaccinated"
end
context "with two given consents with different methods" do
@@ -115,6 +137,8 @@
end
it { should be(:conflicts) }
+
+ include_examples "when vaccinated"
end
context "with two given consents, one both and one with injection only" do
@@ -137,6 +161,8 @@
end
it { should be(:given) }
+
+ include_examples "when vaccinated"
end
context "with an invalidated refused and given consent" do
@@ -146,6 +172,8 @@
end
it { should be(:given) }
+
+ include_examples "when vaccinated"
end
context "with a refused and given consent from the same parent at different times" do
@@ -169,6 +197,8 @@
end
it { should be(:given) }
+
+ include_examples "when vaccinated"
end
context "with self-consent" do
@@ -176,10 +206,14 @@
it { should be(:given) }
+ include_examples "when vaccinated"
+
context "and refused parental consent" do
before { create(:consent, :refused, patient:, programme:) }
it { should be(:given) }
+
+ include_examples "when vaccinated"
end
context "and conflicting parental consent" do
@@ -195,6 +229,8 @@
end
it { should be(:given) }
+
+ include_examples "when vaccinated"
end
end
diff --git a/spec/models/patient/vaccination_status_spec.rb b/spec/models/patient/vaccination_status_spec.rb
index 74dfc371b7..7ca5de2b22 100644
--- a/spec/models/patient/vaccination_status_spec.rb
+++ b/spec/models/patient/vaccination_status_spec.rb
@@ -4,11 +4,12 @@
#
# Table name: patient_vaccination_statuses
#
-# id :bigint not null, primary key
-# academic_year :integer not null
-# status :integer default("none_yet"), not null
-# patient_id :bigint not null
-# programme_id :bigint not null
+# id :bigint not null, primary key
+# academic_year :integer not null
+# latest_session_status :integer default("none_yet"), not null
+# status :integer default("none_yet"), not null
+# patient_id :bigint not null
+# programme_id :bigint not null
#
# Indexes
#
@@ -38,7 +39,7 @@
end
describe "#status" do
- subject { patient_vaccination_status.assign_status }
+ subject { patient_vaccination_status.tap(&:assign_status).status.to_sym }
before { patient.strict_loading!(false) }
@@ -92,4 +93,79 @@
it { should be(:none_yet) }
end
end
+
+ describe "#latest_session_status" do
+ subject do
+ patient_vaccination_status
+ .tap(&:assign_status)
+ .latest_session_status
+ .to_sym
+ end
+
+ before do
+ patient.strict_loading!(false)
+ create(:patient_session, patient:, session:)
+ end
+
+ let(:session) { create(:session, programmes: [programme]) }
+
+ context "with no vaccination record" do
+ it { should be(:none_yet) }
+ end
+
+ context "with a vaccination administered" do
+ before { create(:vaccination_record, patient:, session:, programme:) }
+
+ it { should be(:vaccinated) }
+ end
+
+ context "with a vaccination already had" do
+ before do
+ create(
+ :vaccination_record,
+ :not_administered,
+ :already_had,
+ patient:,
+ session:,
+ programme:
+ )
+ end
+
+ it { should be(:already_had) }
+ end
+
+ context "with a vaccination not administered" do
+ before do
+ create(
+ :vaccination_record,
+ :not_administered,
+ patient:,
+ session:,
+ programme:
+ )
+ end
+
+ it { should be(:unwell) }
+ end
+
+ context "with a consent refused" do
+ before { create(:consent, :refused, patient:, programme:) }
+
+ it { should be(:none_yet) }
+ end
+
+ context "with a triage as unsafe to vaccination" do
+ before { create(:triage, :do_not_vaccinate, patient:, programme:) }
+
+ it { should be(:none_yet) }
+ end
+
+ context "with a discarded vaccination administered" do
+ before do
+ create(:vaccination_record, :discarded, patient:, session:, programme:)
+ end
+
+ it { should be(:none_yet) }
+ end
+ end
end
diff --git a/spec/models/patient_spec.rb b/spec/models/patient_spec.rb
index 1ebda50eef..9144758c6e 100644
--- a/spec/models/patient_spec.rb
+++ b/spec/models/patient_spec.rb
@@ -908,6 +908,43 @@
end
end
+ describe "#should_sync_vaccinations_to_nhs_immunisations_api" do
+ subject(:should_sync_vaccinations_to_nhs_immunisations_api?) do
+ patient.send(:should_sync_vaccinations_to_nhs_immunisations_api?)
+ end
+
+ let(:patient) { create(:patient, nhs_number: "9449310475") }
+ let(:programme) { create(:programme, type: "hpv") }
+ let(:session) { create(:session, programmes: [programme]) }
+ let(:vaccination_record) do
+ create(:vaccination_record, patient:, programme:, session:)
+ end
+
+ context "when nhs_number changes" do
+ it "syncs vaccination records to NHS Immunisations API" do
+ patient.update!(nhs_number: "9449304130")
+
+ expect(should_sync_vaccinations_to_nhs_immunisations_api?).to be_truthy
+ end
+ end
+
+ context "when invalidated_at changes" do
+ it "syncs vaccination records to NHS Immunisations API" do
+ patient.update!(invalidated_at: Time.current)
+
+ expect(should_sync_vaccinations_to_nhs_immunisations_api?).to be_truthy
+ end
+ end
+
+ context "when other attributes change" do
+ it "does not sync vaccination records to NHS Immunisations API" do
+ patient.update!(given_name: "NewName")
+
+ expect(should_sync_vaccinations_to_nhs_immunisations_api?).to be_falsy
+ end
+ end
+ end
+
describe "#stage_changes" do
let(:patient) { create(:patient, given_name: "John", family_name: "Doe") }
diff --git a/spec/models/reporting_api/one_time_token_spec.rb b/spec/models/reporting_api/one_time_token_spec.rb
new file mode 100644
index 0000000000..48d38c8d95
--- /dev/null
+++ b/spec/models/reporting_api/one_time_token_spec.rb
@@ -0,0 +1,171 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: reporting_api_one_time_tokens
+#
+# cis2_info :jsonb not null
+# token :string not null, primary key
+# created_at :datetime not null
+# updated_at :datetime not null
+# user_id :bigint not null
+#
+# Indexes
+#
+# index_reporting_api_one_time_tokens_on_created_at (created_at)
+# index_reporting_api_one_time_tokens_on_token (token) UNIQUE
+# index_reporting_api_one_time_tokens_on_user_id (user_id) UNIQUE
+#
+# Foreign Keys
+#
+# fk_rails_... (user_id => users.id)
+#
+
+describe ReportingAPI::OneTimeToken do
+ subject { described_class.new(user_id: user.id, token: SecureRandom.hex(32)) }
+
+ let(:user) { create(:user) }
+
+ let(:cis2_info) do
+ {
+ "user" => {
+ "id" => 1234,
+ "email" => "test.user@example.com"
+ },
+ "org" => {
+ "id" => 2345,
+ "name" => "Test Org 1"
+ }
+ }
+ end
+
+ describe "validations" do
+ it { should validate_uniqueness_of(:user_id) }
+ it { should validate_presence_of(:user_id) }
+ it { should validate_uniqueness_of(:token) }
+ it { should validate_presence_of(:token) }
+ end
+
+ describe ".generate!" do
+ let(:generate) do
+ described_class.generate!(user_id: user_id, cis2_info: cis2_info)
+ end
+
+ context "given a valid user_id" do
+ let(:user_id) { user.id }
+
+ it "creates a OneTimeToken with the given user_id" do
+ expect { generate }.to change(
+ described_class.where(user_id: user_id),
+ :count
+ ).by(1)
+ end
+
+ describe "the generated OneTimeToken" do
+ let(:generated_token) { described_class.find_by(user_id: user_id) }
+
+ it "has the given cis2_info" do
+ generate
+ expect(generated_token.cis2_info).to eq(cis2_info)
+ end
+
+ it "has a hex string as :token" do
+ generate
+ expect(generated_token.token).to match(/[a-fA-F0-9]{32,}/)
+ end
+ end
+ end
+
+ context "given an invalid user_id" do
+ let(:user_id) { nil }
+
+ it "raises an ActiveRecord::RecordInvalid error" do
+ expect { generate }.to raise_error(ActiveRecord::RecordInvalid)
+ end
+
+ it "does not generate a token" do
+ expect {
+ begin
+ generate
+ rescue ActiveRecord::RecordInvalid => _e
+ nil
+ end
+ }.not_to change(described_class, :count)
+ end
+ end
+ end
+
+ describe "#find_or_generate_for!" do
+ context "given a valid user" do
+ context "when there is no existing token for that user" do
+ before { described_class.where(user: user).delete_all }
+
+ it "generates a token for the user" do
+ expect {
+ described_class.find_or_generate_for!(user: user)
+ }.to change(described_class.where(user_id: user.id), :count).by(1)
+ end
+
+ it "returns the new token" do
+ expect(described_class.find_or_generate_for!(user: user)).to be_a(
+ described_class
+ )
+ end
+ end
+
+ context "when there is an existing token for the user" do
+ let!(:existing_token) do
+ create(
+ :reporting_api_one_time_token,
+ user_id: user.id,
+ token: "testtoken"
+ )
+ end
+
+ context "that has not expired" do
+ it "returns the existing token" do
+ expect(described_class.find_or_generate_for!(user: user)).to eq(
+ existing_token
+ )
+ end
+
+ it "does not create a new token" do
+ expect {
+ described_class.find_or_generate_for!(user: user)
+ }.not_to change(described_class.where(user_id: user.id), :count)
+ end
+ end
+
+ context "which has expired" do
+ before { existing_token.update!(created_at: Time.current - 1.year) }
+
+ it "deletes the existing token" do
+ described_class.find_or_generate_for!(user: user)
+ expect(described_class.find_by(existing_token.attributes)).to be_nil
+ end
+
+ describe "the returned token" do
+ let(:returned_token) do
+ described_class.find_or_generate_for!(
+ user: user,
+ cis2_info: cis2_info
+ )
+ end
+
+ it "is not the existing token" do
+ expect(returned_token).not_to eq(existing_token)
+ end
+
+ it "has the given user id" do
+ expect(returned_token.user_id).to eq(user.id)
+ end
+
+ it "has the given cis2_info" do
+ expect(returned_token.cis2_info).to eq(cis2_info)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/school_move_spec.rb b/spec/models/school_move_spec.rb
index dda0a87016..12cfaa6a09 100644
--- a/spec/models/school_move_spec.rb
+++ b/spec/models/school_move_spec.rb
@@ -547,7 +547,6 @@
include_examples "creates a log entry"
include_examples "sets the patient school"
- include_examples "keeps the patient in the community clinic"
include_examples "adds the patient to the new school sessions"
include_examples "destroys the school move"
end
@@ -571,7 +570,6 @@
include_examples "creates a log entry"
include_examples "sets the patient school"
- include_examples "keeps the patient in the community clinic"
include_examples "adds the patient to the new school sessions"
include_examples "destroys the school move"
end
@@ -795,7 +793,6 @@
include_examples "creates a log entry"
include_examples "sets the patient school"
- include_examples "keeps the patient in the community clinic"
include_examples "adds the patient to the new school sessions"
include_examples "destroys the school move"
end
@@ -819,7 +816,6 @@
include_examples "creates a log entry"
include_examples "sets the patient school"
- include_examples "keeps the patient in the community clinic"
include_examples "adds the patient to the new school sessions"
include_examples "destroys the school move"
end
diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb
index cef02124b4..5c8cbf80e1 100644
--- a/spec/models/team_spec.rb
+++ b/spec/models/team_spec.rb
@@ -15,6 +15,7 @@
# phone_instructions :string
# privacy_notice_url :string not null
# privacy_policy_url :string not null
+# workgroup :string not null
# created_at :datetime not null
# updated_at :datetime not null
# organisation_id :bigint not null
@@ -24,6 +25,7 @@
#
# index_teams_on_name (name) UNIQUE
# index_teams_on_organisation_id (organisation_id)
+# index_teams_on_workgroup (workgroup) UNIQUE
#
# Foreign Keys
#
@@ -43,8 +45,10 @@
it { should validate_presence_of(:name) }
it { should validate_presence_of(:phone) }
it { should validate_presence_of(:privacy_policy_url) }
+ it { should validate_presence_of(:workgroup) }
it { should validate_uniqueness_of(:name) }
+ it { should validate_uniqueness_of(:workgroup) }
end
it_behaves_like "a model with a normalised email address"
diff --git a/spec/models/triage_spec.rb b/spec/models/triage_spec.rb
index 4440dccca3..4b4c810c3f 100644
--- a/spec/models/triage_spec.rb
+++ b/spec/models/triage_spec.rb
@@ -2,7 +2,7 @@
# == Schema Information
#
-# Table name: triage
+# Table name: triages
#
# id :bigint not null, primary key
# academic_year :integer not null
@@ -19,11 +19,11 @@
#
# Indexes
#
-# index_triage_on_academic_year (academic_year)
-# index_triage_on_patient_id (patient_id)
-# index_triage_on_performed_by_user_id (performed_by_user_id)
-# index_triage_on_programme_id (programme_id)
-# index_triage_on_team_id (team_id)
+# index_triages_on_academic_year (academic_year)
+# index_triages_on_patient_id (patient_id)
+# index_triages_on_performed_by_user_id (performed_by_user_id)
+# index_triages_on_programme_id (programme_id)
+# index_triages_on_team_id (team_id)
#
# Foreign Keys
#
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 49a7e14870..a6b0c418e9 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -4,28 +4,30 @@
#
# Table name: users
#
-# id :bigint not null, primary key
-# current_sign_in_at :datetime
-# current_sign_in_ip :string
-# email :string
-# encrypted_password :string default(""), not null
-# fallback_role :integer default("nurse"), not null
-# family_name :string not null
-# given_name :string not null
-# last_sign_in_at :datetime
-# last_sign_in_ip :string
-# provider :string
-# remember_created_at :datetime
-# session_token :string
-# sign_in_count :integer default(0), not null
-# uid :string
-# created_at :datetime not null
-# updated_at :datetime not null
+# id :bigint not null, primary key
+# current_sign_in_at :datetime
+# current_sign_in_ip :string
+# email :string
+# encrypted_password :string default(""), not null
+# fallback_role :integer default("nurse"), not null
+# family_name :string not null
+# given_name :string not null
+# last_sign_in_at :datetime
+# last_sign_in_ip :string
+# provider :string
+# remember_created_at :datetime
+# reporting_api_session_token :string
+# session_token :string
+# sign_in_count :integer default(0), not null
+# uid :string
+# created_at :datetime not null
+# updated_at :datetime not null
#
# Indexes
#
-# index_users_on_email (email) UNIQUE
-# index_users_on_provider_and_uid (provider,uid) UNIQUE
+# index_users_on_email (email) UNIQUE
+# index_users_on_provider_and_uid (provider,uid) UNIQUE
+# index_users_on_reporting_api_session_token (reporting_api_session_token) UNIQUE
#
describe User do
diff --git a/spec/policies/session_attendance_policy_spec.rb b/spec/policies/session_attendance_policy_spec.rb
index a561fea163..fb947bee12 100644
--- a/spec/policies/session_attendance_policy_spec.rb
+++ b/spec/policies/session_attendance_policy_spec.rb
@@ -5,73 +5,99 @@
let(:user) { create(:nurse) }
- let(:programme) { create(:programme) }
- let(:team) { create(:team, programmes: [programme]) }
- let(:session) { create(:session, team:, programmes: [programme]) }
- let(:patient) { create(:patient) }
- let(:patient_session) { create(:patient_session, patient:, session:) }
+ let(:programmes) { [create(:programme, :hpv), create(:programme, :flu)] }
+ let(:team) { create(:team, programmes:) }
+ let(:session) { create(:session, team:, programmes:) }
+ let(:patient) { create(:patient, session:, year_group: 8) }
- shared_examples "allow if not yet seen by nurse" do
+ let(:patient_session) { patient.patient_sessions.includes(:session).first }
+
+ shared_examples "allow if not yet vaccinated or seen by nurse" do
context "with a new session attendance" do
let(:session_attendance) { build(:session_attendance, patient_session:) }
it { should be(true) }
end
- context "with session attendance and a vaccination record" do
+ context "with session attendance and one vaccination record from a different session" do
let(:session_attendance) { build(:session_attendance, patient_session:) }
before do
create(
:vaccination_record,
patient:,
- session:,
- programme:,
+ programme: programmes.first,
performed_at: Time.current
)
+
+ StatusUpdater.call(patient:)
+ end
+
+ it { should be(true) }
+ end
+
+ context "with session attendance and both vaccination records" do
+ let(:session_attendance) { build(:session_attendance, patient_session:) }
+
+ before do
+ programmes.each do |programme|
+ create(
+ :vaccination_record,
+ patient:,
+ session:,
+ programme:,
+ performed_at: Time.current
+ )
+ end
+
+ StatusUpdater.call(patient:)
end
it { should be(false) }
end
- context "with session attendance and a vaccination record from a different date" do
+ context "with session attendance and both vaccination records from a different date" do
let(:session_attendance) { build(:session_attendance, patient_session:) }
before do
- create(
- :vaccination_record,
- patient:,
- session:,
- programme:,
- performed_at: Time.zone.yesterday
- )
+ programmes.each do |programme|
+ create(
+ :vaccination_record,
+ patient:,
+ session:,
+ programme:,
+ performed_at: Time.zone.yesterday
+ )
+ end
+
+ StatusUpdater.call(patient:)
end
- it { should be(true) }
+ it { should be(false) }
end
end
describe "#new?" do
subject(:new?) { policy.new? }
- include_examples "allow if not yet seen by nurse"
+ include_examples "allow if not yet vaccinated or seen by nurse"
end
describe "#create?" do
subject(:create?) { policy.create? }
- include_examples "allow if not yet seen by nurse"
+ include_examples "allow if not yet vaccinated or seen by nurse"
end
describe "#edit?" do
subject(:edit?) { policy.edit? }
- include_examples "allow if not yet seen by nurse"
+ include_examples "allow if not yet vaccinated or seen by nurse"
end
describe "#update?" do
subject(:update?) { policy.update? }
- include_examples "allow if not yet seen by nurse"
+ include_examples "allow if not yet vaccinated or seen by nurse"
end
end
diff --git a/spec/requests/api/testing/onboard_spec.rb b/spec/requests/api/testing/onboard_spec.rb
index ba58d6aba3..d58f137e0d 100644
--- a/spec/requests/api/testing/onboard_spec.rb
+++ b/spec/requests/api/testing/onboard_spec.rb
@@ -64,6 +64,7 @@
"team.phone" => ["can't be blank", "is invalid"],
"team.privacy_notice_url" => ["can't be blank"],
"team.privacy_policy_url" => ["can't be blank"],
+ "team.workgroup" => ["can't be blank"],
"programmes" => ["can't be blank"],
"school.0.location" => ["can't be blank"],
"school.0.status" => ["is not included in the list"],
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index fb1aa9f1e6..f93a22db64 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -208,6 +208,10 @@
SMSDeliveryJob.deliveries.clear
end
+ config.before(:all, type: :feature) do
+ MavisCLI.instance_variable_set(:@progress_bar, nil)
+ end
+
config.include ActiveJob::TestHelper, type: :feature
config.include ActiveSupport::Testing::TimeHelpers
config.include Capybara::RSpecMatchers, type: :component
diff --git a/spec/support/pds_helper.rb b/spec/support/pds_helper.rb
index 3674f9f4c1..a593eff511 100644
--- a/spec/support/pds_helper.rb
+++ b/spec/support/pds_helper.rb
@@ -33,4 +33,17 @@ def stub_pds_get_nhs_number_to_return_a_patient
}
)
end
+
+ def stub_pds_get_nhs_number_to_return_an_invalidated_patient
+ stub_request(
+ :get,
+ "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient/#{@patient.nhs_number}"
+ ).to_return(
+ body: file_fixture("pds/invalid-patient-response.json"),
+ status: 404,
+ headers: {
+ "Content-Type" => "application/fhir+json"
+ }
+ )
+ end
end
diff --git a/yarn.lock b/yarn.lock
index a461347601..3124f6c9f9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5736,10 +5736,10 @@ sane@^4.0.3:
minimist "^1.1.1"
walker "~1.0.5"
-sass@^1.89.2:
- version "1.89.2"
- resolved "https://registry.yarnpkg.com/sass/-/sass-1.89.2.tgz#a771716aeae774e2b529f72c0ff2dfd46c9de10e"
- integrity sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==
+sass@^1.90.0:
+ version "1.90.0"
+ resolved "https://registry.yarnpkg.com/sass/-/sass-1.90.0.tgz#d6fc2be49c7c086ce86ea0b231a35bf9e33cb84b"
+ integrity sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q==
dependencies:
chokidar "^4.0.0"
immutable "^5.0.2"