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 AppPatientSearchFormComponent.new(@form, url: search_consent_form_path(@consent_form), heading_level: 2) %> + <%= render AppPatientSearchFormComponent.new( + @form, + url: search_consent_form_path(@consent_form), + heading_level: 2, + show_aged_out_of_programmes: true, + ) %> <%= render AppSearchResultsComponent.new(@pagy, label: "children") do %> <% @patients.each do |patient| %> diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index 6d93a67b48..e14e2054d5 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -1,6 +1,6 @@ <%= h1 "#{@service_name} (Mavis)", size: "xl" %> -<% if (count = @important_notices) %> +<% if (count = @notices_count) %> <%= render AppWarningCalloutComponent.new(heading: "Important notices") do %>

<%= link_to t(".notices.description", count:), imports_notices_path %>

<% end %> diff --git a/app/views/imports/notices/index.html.erb b/app/views/imports/notices/index.html.erb index ae5e446779..3b4e2c4b0b 100644 --- a/app/views/imports/notices/index.html.erb +++ b/app/views/imports/notices/index.html.erb @@ -4,13 +4,8 @@ <%= render AppImportsNavigationComponent.new(active: :notices) %> -<% if @deceased_patients.any? || @invalidated_patients.any? || @restricted_patients.any? || @has_vaccination_records_dont_notify_parents_patients.any? %> - <%= render AppNoticesTableComponent.new( - deceased_patients: @deceased_patients, - invalidated_patients: @invalidated_patients, - restricted_patients: @restricted_patients, - has_vaccination_records_dont_notify_parents_patients: @has_vaccination_records_dont_notify_parents_patients, - ) %> +<% if @notices.present? %> + <%= render AppNoticesTableComponent.new(@notices) %> <% else %>

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

<% end %> diff --git a/app/views/patients/index.html.erb b/app/views/patients/index.html.erb index e8812c97cd..499a22b3a2 100644 --- a/app/views/patients/index.html.erb +++ b/app/views/patients/index.html.erb @@ -1,6 +1,11 @@ <%= h1 t(".title"), size: "xl" %> -<%= render AppPatientSearchFormComponent.new(@form, url: patients_path, heading_level: 2) %> +<%= render AppPatientSearchFormComponent.new( + @form, + url: patients_path, + heading_level: 2, + show_aged_out_of_programmes: true, + ) %> <% if @patients.any? %> <%= render AppPatientTableComponent.new(@patients, current_user:, count: @pagy.count) %> diff --git a/app/views/patients/show.html.erb b/app/views/patients/show.html.erb index cbe0023316..f7e4c1ca2e 100644 --- a/app/views/patients/show.html.erb +++ b/app/views/patients/show.html.erb @@ -21,9 +21,17 @@ <% end %> <% end %> +<%= render AppCardComponent.new(section: true) do |card| %> + <%= render AppPatientProgrammesTableComponent.new(@patient, programmes: policy_scope(Programme)) %> +<% end %> + <%= render AppCardComponent.new(section: true) do |card| %> <% card.with_heading { "Sessions" } %> <%= render AppPatientSessionTableComponent.new(@patient_sessions) %> + + <% unless @patient.in_generic_clinic?(team: current_team) %> + <%= govuk_button_to "Invite to community clinic", invite_to_clinic_patient_path(@patient), secondary: true %> + <% end %> <% end %> <%= render AppCardComponent.new(section: true) do |card| %> diff --git a/app/views/programmes/patients/index.html.erb b/app/views/programmes/patients/index.html.erb index e17366d1c2..cb4af901e9 100644 --- a/app/views/programmes/patients/index.html.erb +++ b/app/views/programmes/patients/index.html.erb @@ -22,7 +22,7 @@ @form, url: programme_patients_path(@programme), programme_statuses: Patient::VaccinationStatus.statuses.keys, - consent_statuses: Patient::ConsentStatus.statuses.keys, + consent_statuses: %w[no_response given refused conflicts], triage_statuses: %w[required delay_vaccination do_not_vaccinate safe_to_vaccinate], year_groups: @year_groups, ) %> diff --git a/app/views/sessions/_header.html.erb b/app/views/sessions/_header.html.erb index 4e2eb2b1a2..5a20fa2854 100644 --- a/app/views/sessions/_header.html.erb +++ b/app/views/sessions/_header.html.erb @@ -12,6 +12,12 @@ selected: request.path == session_path(@session), ) + nav.with_item( + href: session_patients_path(@session), + text: t("sessions.tabs.patients"), + selected: request.path == session_patients_path(@session), + ) + nav.with_item( href: session_consent_path(@session), text: t("sessions.tabs.consent"), @@ -37,10 +43,4 @@ text: t("sessions.tabs.record"), selected: request.path == session_record_path(@session), ) - - nav.with_item( - href: session_outcome_path(@session), - text: t("sessions.tabs.outcome"), - selected: request.path == session_outcome_path(@session), - ) end %> diff --git a/app/views/sessions/consent/show.html.erb b/app/views/sessions/consent/show.html.erb index dfa2338d3b..d736b69cbf 100644 --- a/app/views/sessions/consent/show.html.erb +++ b/app/views/sessions/consent/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/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"