diff --git a/.github/workflows/deploy-application.yml b/.github/workflows/deploy-application.yml index 46065da354..4b568bee92 100644 --- a/.github/workflows/deploy-application.yml +++ b/.github/workflows/deploy-application.yml @@ -17,6 +17,7 @@ on: - sandbox-alpha - sandbox-beta - performance + - pentest server_types: description: Server types to deploy required: true diff --git a/.github/workflows/deploy-documentation.yml b/.github/workflows/deploy-documentation.yml new file mode 100644 index 0000000000..e557a03232 --- /dev/null +++ b/.github/workflows/deploy-documentation.yml @@ -0,0 +1,35 @@ +name: Deploy Documentation + +on: + push: + branches: + - release + - next + +permissions: + contents: write + +jobs: + deploy-documentation: + name: Generate and Publish RDoc + runs-on: ubuntu-latest + concurrency: + group: deploy-documentation-${{ github.ref_name }} + cancel-in-progress: true + + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Generate Documentation + run: bundle exec rake rdoc:generate + + - name: Deploy to GitHub Pages + uses: JamesIves/github-pages-deploy-action@v4 + with: + branch: gh-pages + folder: docs/rdoc + target-folder: docs/rdoc/${{ github.ref_name }} + clean: false diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5732a61f5d..aca889ece0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -34,6 +34,7 @@ on: - sandbox-alpha - sandbox-beta - performance + - pentest server_types: description: Server types to deploy required: true diff --git a/Gemfile.lock b/Gemfile.lock index 28448b96c4..89f24bab62 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/alphagov/rubocop-govuk.git - revision: 58ff3290bf5c267d0228d0910fa0bc92a405ec81 + revision: cb59fb73abfb010ea000ed53dcc84a3e20c28974 branch: main specs: rubocop-govuk (5.1.20) @@ -136,7 +136,7 @@ GEM ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1206.0) + aws-partitions (1.1208.0) aws-sdk-accessanalyzer (1.85.0) aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) @@ -148,7 +148,7 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-ec2 (1.591.0) + aws-sdk-ec2 (1.592.0) aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) aws-sdk-ecr (1.119.0) @@ -243,7 +243,7 @@ GEM docile (1.4.1) domain_name (0.6.20240107) drb (2.2.3) - dry-cli (1.4.0) + dry-cli (1.4.1) email_validator (2.2.4) activemodel erb (6.0.1) @@ -486,7 +486,7 @@ GEM date stringio public_suffix (7.0.2) - puma (7.1.0) + puma (7.2.0) nio4r (~> 2.0) pundit (2.5.2) activesupport (>= 3.0.0) @@ -674,7 +674,7 @@ GEM sidekiq-scheduler (6.0.1) rufus-scheduler (~> 3.2) sidekiq (>= 7.3, < 9) - sidekiq-throttled (2.0.0) + sidekiq-throttled (2.1.0) concurrent-ruby (>= 1.2.0) redis-prescription (~> 2.2) sidekiq (>= 8.0) @@ -786,7 +786,7 @@ GEM websocket-extensions (0.1.5) wicked (2.0.0) railties (>= 3.0.7) - with_advisory_lock (7.0.2) + with_advisory_lock (7.5.0) activerecord (>= 7.2) zeitwerk (>= 2.7) xpath (3.2.0) diff --git a/README.md b/README.md index 7faca55a50..8294796134 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,13 @@ This is a service used within the NHS for managing and recording school-aged vac | [Training](https://github.com/nhsuk/manage-vaccinations-in-schools/deployments/training) | [training.manage-vaccinations-in-schools.nhs.uk](https://training.manage-vaccinations-in-schools.nhs.uk) | External training | ❌ | `release` branch | manual | [`staging`](config/environments/staging.rb) | | [Production](https://github.com/nhsuk/manage-vaccinations-in-schools/deployments/production) | [www.manage-vaccinations-in-schools.nhs.uk](https://www.manage-vaccinations-in-schools.nhs.uk) | Live service | ✅ | `release` branch | manual | [`production`](config/environments/production.rb) | +## Documentation + +We have two Rdoc versions: + +1. [next](https://nhsuk.github.io/manage-vaccinations-in-schools/rdoc/next) - useful for dev work (based off the `next` branch). +2. [release](https://nhsuk.github.io/manage-vaccinations-in-schools/rdoc/release) - useful for ops to debug live issues (based off the `release` branch). + ## Development ### Prerequisites diff --git a/app/assets/stylesheets/components/_index.scss b/app/assets/stylesheets/components/_index.scss index 410c51ad32..eaf7baa127 100644 --- a/app/assets/stylesheets/components/_index.scss +++ b/app/assets/stylesheets/components/_index.scss @@ -14,6 +14,7 @@ @forward "session-banner"; @forward "status"; @forward "sticky-navigation"; +@forward "sub-navigation"; @forward "summary-list"; @forward "tables"; @forward "tag"; diff --git a/app/assets/stylesheets/components/_sub-navigation.scss b/app/assets/stylesheets/components/_sub-navigation.scss new file mode 100644 index 0000000000..5442f3ec95 --- /dev/null +++ b/app/assets/stylesheets/components/_sub-navigation.scss @@ -0,0 +1,67 @@ +@use "../vendor/nhsuk-frontend" as *; + +$_current-indicator-width: 4px; + +.app-sub-navigation { + @include nhsuk-font(16); +} + +.app-sub-navigation__section { + margin: 0 0 nhsuk-spacing(4); + padding: 0; + list-style-type: none; + + @include nhsuk-font(16); +} + +.app-sub-navigation__link { + padding-top: nhsuk-spacing(1); + padding-bottom: nhsuk-spacing(1); + + @include nhsuk-link-style-default; + @include nhsuk-link-style-no-visited-state; + + &:link { + text-decoration: none; + } + + &:not(:focus):hover { + color: $nhsuk-link-colour; + } +} + +.app-sub-navigation__section-item { + margin-bottom: nhsuk-spacing(1); + padding-top: nhsuk-spacing(1); + padding-bottom: nhsuk-spacing(1); +} + +.app-sub-navigation__section-item--current { + margin-left: -(nhsuk-spacing(2) + $_current-indicator-width); + padding-left: nhsuk-spacing(2); + border-left: $_current-indicator-width solid $nhsuk-link-colour; +} + +.app-sub-navigation__link[aria-current] { + font-weight: bold; +} + +.app-sub-navigation__section--nested { + margin-top: nhsuk-spacing(2); + margin-bottom: 0; + padding-left: nhsuk-spacing(4); +} + +.app-sub-navigation__section--nested .app-sub-navigation__section-item::before { + content: "—"; + margin-left: nhsuk-spacing(-4); + color: $nhsuk-secondary-text-colour; +} + +.app-sub-navigation__theme { + margin: 0; + padding: nhsuk-spacing(2) nhsuk-spacing(3) nhsuk-spacing(2) 0; + color: $nhsuk-secondary-text-colour; + + @include nhsuk-font(19, $weight: bold); +} diff --git a/app/components/app_activity_log_component.rb b/app/components/app_activity_log_component.rb index 6596620bab..43fc861273 100644 --- a/app/components/app_activity_log_component.rb +++ b/app/components/app_activity_log_component.rb @@ -203,12 +203,12 @@ def expiration_events not_vaccinated_programmes = all_programmes.reject do |programme| - patient.vaccination_status(programme:, academic_year:).vaccinated? + patient.programme_status(programme, academic_year:).vaccinated? end vaccinated_but_seasonal_programmes = all_programmes.select do |programme| - patient.vaccination_status(programme:, academic_year:).vaccinated? && + patient.programme_status(programme, academic_year:).vaccinated? && programme.seasonal? end diff --git a/app/components/app_imports_navigation_component.rb b/app/components/app_imports_navigation_component.rb index e0d8732fff..20bbd8081d 100644 --- a/app/components/app_imports_navigation_component.rb +++ b/app/components/app_imports_navigation_component.rb @@ -20,11 +20,13 @@ def call selected: active == :imported ) - nav.with_item( - href: imports_issues_path, - text: issues_text, - selected: active == :issues - ) + if policy(%i[import issue]).index? + nav.with_item( + href: imports_issues_path, + text: issues_text, + selected: active == :issues + ) + end if policy(ImportantNotice).index? nav.with_item( diff --git a/app/components/app_patient_programmes_table_component.rb b/app/components/app_patient_programmes_table_component.rb index 9b35c79c07..9125a628e3 100644 --- a/app/components/app_patient_programmes_table_component.rb +++ b/app/components/app_patient_programmes_table_component.rb @@ -49,10 +49,12 @@ def non_seasonal_programme_rows(programme:) end def build_row(programme:, academic_year:) + programme_type = programme.type + [ name_for_programme(programme:, academic_year:), - status_for_programme(programme:, academic_year:), - notes_for_programme(programme:, academic_year:) + status_for_programme(programme_type:, academic_year:), + notes_for_programme(programme_type:, academic_year:) ] end @@ -64,25 +66,26 @@ def name_for_programme(programme:, academic_year:) end end - def status_for_programme(programme:, academic_year:) - hash = programme_status_hash(programme:, academic_year:) + def status_for_programme(programme_type:, academic_year:) + hash = programme_status_hash(programme_type:, academic_year:) tag.strong(hash[:text], class: "nhsuk-tag nhsuk-tag--#{hash[:colour]}") end - def notes_for_programme(programme:, academic_year:) - programme_status_hash(programme:, academic_year:)[:details_text].presence || - "" + def notes_for_programme(programme_type:, academic_year:) + programme_status_hash(programme_type:, academic_year:)[ + :details_text + ].presence || "" end - def programme_status_hash(programme:, academic_year:) + def programme_status_hash(programme_type:, academic_year:) @programme_status_hash ||= {} - @programme_status_hash[programme.type] ||= {} - @programme_status_hash[programme.type][ + @programme_status_hash[programme_type] ||= {} + @programme_status_hash[programme_type][ academic_year - ] ||= PatientStatusResolver.new( + ] ||= PatientProgrammeStatusResolver.call( patient, - programme:, + programme_type:, academic_year: - ).programme + ) end end diff --git a/app/components/app_patient_search_result_card_component.rb b/app/components/app_patient_search_result_card_component.rb index 4d7e7ae39c..f57796b2d9 100644 --- a/app/components/app_patient_search_result_card_component.rb +++ b/app/components/app_patient_search_result_card_component.rb @@ -11,8 +11,8 @@ def initialize( show_parents: false, show_postcode: false, show_programme_status: true, - show_vaccinated_programme_status_only: false, show_school: false, + show_vaccinated_programme_status_only: false, show_year_group: false ) @patient = patient @@ -26,9 +26,9 @@ def initialize( @show_parents = show_parents @show_postcode = show_postcode @show_programme_status = show_programme_status + @show_school = show_school @show_vaccinated_programme_status_only = show_vaccinated_programme_status_only - @show_school = show_school @show_year_group = show_year_group end @@ -71,10 +71,11 @@ def call row.with_value { patient_parents(patient) } end end - if show_programme_status && academic_year && programme_status_tag + if show_programme_status && academic_year && + (value = programme_status_tag) summary_list.with_row do |row| row.with_key { "Programme status" } - row.with_value { programme_status_tag } + row.with_value { value } end end end @@ -92,9 +93,10 @@ def call :show_nhs_number, :show_parents, :show_postcode, + :show_programme_status, :show_school, - :show_year_group, - :show_programme_status + :show_vaccinated_programme_status_only, + :show_year_group delegate :govuk_summary_list, :patient_date_of_birth, @@ -108,8 +110,11 @@ def programme_status_tag status_by_programme = programmes.each_with_object({}) do |programme, hash| resolved_status = - status_resolver_for(programme).programme( - only_if_vaccinated: @show_vaccinated_programme_status_only + PatientProgrammeStatusResolver.call( + patient, + programme_type: programme.type, + academic_year:, + only_if_vaccinated: show_vaccinated_programme_status_only ) next unless resolved_status @@ -121,13 +126,4 @@ def programme_status_tag render AppAttachedTagsComponent.new(status_by_programme) end - - def status_resolver_for(programme) - @status_resolver_for ||= {} - @status_resolver_for[programme.type] ||= PatientStatusResolver.new( - patient, - programme:, - academic_year: - ) - end end diff --git a/app/components/app_patient_session_consent_component.html.erb b/app/components/app_patient_session_consent_component.html.erb index b0c0e1510d..c8eada95af 100644 --- a/app/components/app_patient_session_consent_component.html.erb +++ b/app/components/app_patient_session_consent_component.html.erb @@ -3,19 +3,19 @@ <%= render AppCardComponent.new(feature: true) do |card| %> <% card.with_heading(level: 4, colour:) { heading } %> - <% unless vaccination_status.vaccinated? %> - <% if consent_status.no_response? %> + <% unless programme_status.vaccinated? %> + <% if consent_status_value == :no_response %> <% if latest_consent_request %>

No-one responded to our requests for consent.

A request was sent on <%= latest_consent_request.sent_at.to_fs(:long) %>.

<% else %>

No requests have been sent.

<% end %> - <% elsif consent_status.conflicts? %> + <% elsif consent_status_value == :conflicts %>

You can only vaccinate if all respondents give consent.

- <% elsif consent_status.refused? %> + <% elsif consent_status_value == :refused %>

<%= who_refused %> refused to give consent.

- <% elsif consent_status.given? %> + <% elsif consent_status_generator.status == :given %>

<%= patient.full_name %> is ready for the vaccinator.

<% end %> diff --git a/app/components/app_patient_session_consent_component.rb b/app/components/app_patient_session_consent_component.rb index 0337f51a76..a0d3ebb0d0 100644 --- a/app/components/app_patient_session_consent_component.rb +++ b/app/components/app_patient_session_consent_component.rb @@ -1,12 +1,80 @@ # frozen_string_literal: true -class AppPatientSessionConsentComponent < AppPatientSessionSectionComponent +class AppPatientSessionConsentComponent < ViewComponent::Base + def initialize(patient:, session:, programme:) + @patient = patient + @session = session + @programme = programme + end + private + attr_reader :patient, :session, :programme + + delegate :academic_year, :team, to: :session + delegate :govuk_button_to, to: :helpers - def resolved_status - @resolved_status ||= patient_status_resolver.consent + def programme_type = programme.type + + def colour + I18n.t(consent_status_value, scope: %i[status consent colour]) + end + + def heading + status_text = I18n.t(consent_status_value, scope: %i[status consent label]) + "#{consent_status_generator.programme.name}: #{status_text}" + end + + def consent_status_value + @consent_status_value ||= + if consent_status_generator.status == :given + vaccine_method = + triage_status_generator.vaccine_method.presence || + consent_status_generator.vaccine_methods.first + + without_gelatine = + triage_status_generator.without_gelatine || + consent_status_generator.without_gelatine + + parts = [ + "given", + vaccine_method, + without_gelatine ? "without_gelatine" : nil, + without_gelatine && programme.flu? ? "flu" : nil + ] + + parts.compact_blank.join("_") + else + consent_status_generator.status + end + end + + def programme_status + @programme_status ||= patient.programme_status(programme, academic_year:) + end + + def consent_status_generator + @consent_status_generator ||= + StatusGenerator::Consent.new( + programme_type:, + academic_year:, + patient:, + consents:, + vaccination_records: + ) + end + + def triage_status_generator + @triage_status_generator ||= + StatusGenerator::Triage.new( + programme_type:, + academic_year:, + patient:, + consents:, + triages:, + vaccination_records: + ) end def latest_consent_request @@ -31,28 +99,33 @@ def consents .order(created_at: :desc) end - def consent_status - @consent_status ||= patient.consent_status(programme:, academic_year:) + def triages + @triages ||= + patient + .triages + .for_programme(programme) + .where(academic_year:) + .not_invalidated + .order(created_at: :desc) end - def vaccination_status - @vaccination_status ||= - patient.vaccination_status(programme:, academic_year:) + def vaccination_records + @vaccination_records ||= + patient + .vaccination_records + .for_programme(programme) + .order(performed_at: :desc) end def can_send_consent_request? - consent_status.no_response? && + consent_status_value == :no_response && patient.send_notifications?(team: @session.team) && session.can_receive_consent? && patient.parents.any? end def grouped_consents @grouped_consents ||= - ConsentGrouper.call( - consents, - programme_type: programme.type, - academic_year: - ) + ConsentGrouper.call(consents, programme_type:, academic_year:) end def who_refused 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 0ef4579e47..e9b52dc1d4 100644 --- a/app/components/app_patient_session_search_result_card_component.rb +++ b/app/components/app_patient_session_search_result_card_component.rb @@ -179,12 +179,12 @@ def programme_status_tag status_by_programme = programmes.each_with_object({}) do |programme, hash| resolved_status = - PatientStatusResolver.new( + PatientProgrammeStatusResolver.call( patient, - programme:, + programme_type: programme.type, academic_year:, context_location_id: session.location_id - ).programme + ) hash[resolved_status.fetch(:prefix)] = resolved_status end diff --git a/app/components/app_patient_session_section_component.rb b/app/components/app_patient_session_section_component.rb deleted file mode 100644 index 89ad149bd4..0000000000 --- a/app/components/app_patient_session_section_component.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -class AppPatientSessionSectionComponent < ViewComponent::Base - def initialize(patient:, session:, programme:) - @patient = patient - @session = session - @programme = programme - end - - private - - attr_reader :patient, :session, :programme - - delegate :academic_year, :team, to: :session - - def colour = resolved_status.fetch(:colour) - - def heading = - "#{resolved_status.fetch(:prefix)}: #{resolved_status.fetch(:text)}" - - def patient_status_resolver - PatientStatusResolver.new( - patient, - programme:, - academic_year:, - context_location_id: session.location_id - ) - end -end diff --git a/app/components/app_patient_session_triage_component.html.erb b/app/components/app_patient_session_triage_component.html.erb index 8713ec21f5..8f870f782a 100644 --- a/app/components/app_patient_session_triage_component.html.erb +++ b/app/components/app_patient_session_triage_component.html.erb @@ -3,7 +3,7 @@ <%= render AppCardComponent.new(feature: true) do |card| %> <% card.with_heading(level: 4, colour:) { heading } %> - <% if triage_status.not_required? %> + <% if triage_status_value == :not_required %>

No triage is needed for <%= patient.full_name %>.

@@ -17,7 +17,7 @@ <% if helpers.policy(Triage).new? %>

You need to decide if <%= patient.full_name %> is safe to vaccinate.

- <% if triage_status&.vaccination_history_requires_triage? %> + <% if triage_status_generator.vaccination_history_requires_triage? %>

Incomplete vaccination history for <%= programme.name_in_sentence %>. Check if the child needs another dose.

<% end %> diff --git a/app/components/app_patient_session_triage_component.rb b/app/components/app_patient_session_triage_component.rb index 7f62b8a486..960f9c1d03 100644 --- a/app/components/app_patient_session_triage_component.rb +++ b/app/components/app_patient_session_triage_component.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class AppPatientSessionTriageComponent < AppPatientSessionSectionComponent +class AppPatientSessionTriageComponent < ViewComponent::Base def initialize( patient:, session:, @@ -8,44 +8,106 @@ def initialize( current_user:, triage_form: nil ) - super(patient:, session:, programme:) + @patient = patient + @session = session + @programme = programme @current_user = current_user @triage_form = triage_form || default_triage_form end - def render? = consent_status.given? || !triage_status.not_required? + def render? + consent_status_generator.status == :given || + triage_status_generator.status != :not_required + end private - attr_reader :current_user, :triage_form + attr_reader :patient, :session, :programme, :current_user, :triage_form + + delegate :academic_year, :team, to: :session delegate :govuk_button_link_to, :triage_summary, to: :helpers def programme_type = programme.type - def resolved_status - @resolved_status ||= patient_status_resolver.triage + def colour + I18n.t(triage_status_value, scope: %i[status triage colour]) end - def triage_status - @triage_status ||= - patient - .triage_statuses - .includes(:consents, :vaccination_records) - .find_or_initialize_by(programme_type:, academic_year:) + def heading + status_text = I18n.t(triage_status_value, scope: %i[status triage label]) + "#{triage_status_generator.programme.name}: #{status_text}" + end + + def triage_status_value + @triage_status_value ||= + if triage_status_generator.status == :safe_to_vaccinate + vaccine_method = triage_status_generator.vaccine_method + without_gelatine = triage_status_generator.without_gelatine + + parts = [ + "safe_to_vaccinate", + vaccine_method, + without_gelatine ? "without_gelatine" : nil, + without_gelatine && programme.flu? ? "flu" : nil + ] + + parts.compact_blank.join("_") + else + triage_status_generator.status + end + end + + def programme_status + @programme_status ||= patient.programme_status(programme, academic_year:) + end + + def triage_status_generator + @triage_status_generator ||= + StatusGenerator::Triage.new( + programme_type:, + academic_year:, + patient:, + consents:, + triages:, + vaccination_records: + ) + end + + def consent_status_generator + @consent_status_generator ||= + StatusGenerator::Consent.new( + programme_type:, + academic_year:, + patient:, + consents:, + vaccination_records: + ) end - def consent_status - patient.consent_status(programme:, academic_year:) + def consents + @consents ||= + patient.consents.not_invalidated.response_provided.order( + created_at: :desc + ) + end + + def triages + @triages ||= + patient.triages.includes(:performed_by).order(created_at: :desc) + end + + def vaccination_records + @vaccination_records ||= + patient + .vaccination_records + .for_programme(programme) + .order(performed_at: :desc) end def latest_triage @latest_triage ||= - TriageFinder.call( - patient.triages.includes(:performed_by), - programme_type: programme.type, - academic_year: - ) + TriageFinder.call(triages, programme_type:, academic_year:) end def default_triage_form diff --git a/app/components/app_patient_session_vaccination_component.rb b/app/components/app_patient_session_vaccination_component.rb index d141dae249..1d606e038b 100644 --- a/app/components/app_patient_session_vaccination_component.rb +++ b/app/components/app_patient_session_vaccination_component.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class AppPatientSessionVaccinationComponent < AppPatientSessionSectionComponent +class AppPatientSessionVaccinationComponent < ViewComponent::Base erb_template <<-ERB

Programme status

@@ -15,6 +15,12 @@ class AppPatientSessionVaccinationComponent < AppPatientSessionSectionComponent <% end %> ERB + def initialize(patient:, session:, programme:) + @patient = patient + @session = session + @programme = programme + end + def render? patient .vaccination_records @@ -24,7 +30,22 @@ def render? private + attr_reader :patient, :session, :programme + + delegate :academic_year, :team, to: :session + + def colour = resolved_status.fetch(:colour) + + def heading = + "#{resolved_status.fetch(:prefix)}: #{resolved_status.fetch(:text)}" + def resolved_status - @resolved_status ||= patient_status_resolver.programme + @resolved_status ||= + PatientProgrammeStatusResolver.call( + patient, + programme_type: programme.type, + academic_year:, + context_location_id: session.location_id + ) end end diff --git a/app/components/app_sub_navigation_component.html.erb b/app/components/app_sub_navigation_component.html.erb new file mode 100644 index 0000000000..8df46775b6 --- /dev/null +++ b/app/components/app_sub_navigation_component.html.erb @@ -0,0 +1,24 @@ + + +

+ <%= selected_item_text %> +

diff --git a/app/components/app_sub_navigation_component.rb b/app/components/app_sub_navigation_component.rb new file mode 100644 index 0000000000..6b85a1e805 --- /dev/null +++ b/app/components/app_sub_navigation_component.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class AppSubNavigationComponent < ViewComponent::Base + renders_many :items, "Item" + + def initialize(classes: nil, attributes: {}) + @classes = ["app-sub-navigation", *Array(classes)].compact.join(" ") + + @attributes = + attributes.merge(class: @classes, "aria-label": "Secondary menu") + end + + def selected_item_text + selected_item = items.find(&:selected) + selected_item&.call + end + + class Item < ViewComponent::Base + def initialize(href:, text: nil, selected: false) + @href = href + @text = html_escape(text) + @selected = selected + end + + def call + content || @text || raise(ArgumentError, "no text or content") + end + + attr_reader :href, :selected, :ticked + end +end diff --git a/app/components/app_team_navigation_component.rb b/app/components/app_team_navigation_component.rb new file mode 100644 index 0000000000..bff1989562 --- /dev/null +++ b/app/components/app_team_navigation_component.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class AppTeamNavigationComponent < ViewComponent::Base + def initialize(team:) + @team = team + end + + def call + render AppSubNavigationComponent.new do |nav| + nav.with_item( + href: contact_details_team_path, + text: "Contact details", + selected: request.path.ends_with?("contact_details") + ) + nav.with_item( + href: clinics_team_path, + text: "Clinics", + selected: request.path.ends_with?("clinics") + ) + nav.with_item( + href: schools_team_path, + text: "Schools", + selected: request.path.ends_with?("schools") + ) + nav.with_item( + href: sessions_team_path, + text: "Sessions", + selected: request.path.ends_with?("sessions") + ) + end + end +end diff --git a/app/components/app_triage_form_component.rb b/app/components/app_triage_form_component.rb index 252294e050..18b5c0eb3d 100644 --- a/app/components/app_triage_form_component.rb +++ b/app/components/app_triage_form_component.rb @@ -53,8 +53,8 @@ def fieldset_options def patient_eligible_for_additional_dose? next_dose = - patient.vaccination_status( - programme: programme, + patient.programme_status( + programme, academic_year: session.academic_year ).dose_sequence diff --git a/app/components/app_vaccination_record_summary_component.rb b/app/components/app_vaccination_record_summary_component.rb index 33d4183d9a..671fffe9b1 100644 --- a/app/components/app_vaccination_record_summary_component.rb +++ b/app/components/app_vaccination_record_summary_component.rb @@ -51,7 +51,13 @@ def call if @vaccine row.with_value { vaccine_value } - if (href = @change_links[:vaccine]) + if @current_user.selected_team.has_upload_only_access? + row.with_action( + text: "Change", + href: @change_links[:batch], + visually_hidden_text: "vaccine" + ) + elsif (href = @change_links[:vaccine]) row.with_action( text: "Change", visually_hidden_text: "vaccine", @@ -89,6 +95,14 @@ def call summary_list.with_row do |row| row.with_key { "Batch expiry date" } row.with_value { batch_expiry_value } + + if @current_user.selected_team.has_upload_only_access? + row.with_action( + text: "Change", + href: @change_links[:batch], + visually_hidden_text: "batch expiry date" + ) + end end end @@ -280,12 +294,12 @@ def call end end - sync_feature_flag_enabled = - Programme.all.any? { Flipper.enabled?(:imms_api_sync_job, it) } + correct_feature_flags_enabled = + Programme.all.any? { Flipper.enabled?(:imms_api_sync_job, it) } && + Flipper.enabled?(:imms_api_integration) if @vaccination_record.respond_to?(:sync_status) && - sync_feature_flag_enabled && - Flipper.enabled?(:imms_api_integration) && - @vaccination_record&.sourced_from_service? + correct_feature_flags_enabled && + @vaccination_record&.correct_source_for_nhs_immunisations_api? summary_list.with_row do |row| row.with_key { "Synced with NHS England?" } row.with_value do @@ -325,7 +339,13 @@ def programme_value end def vaccine_value - highlight_if(@vaccine.brand, @vaccination_record.vaccine_id_changed?) + display_name = + if @current_user.selected_team.has_upload_only_access? + @vaccine.nivs_name.presence || @vaccine.brand + else + @vaccine.brand + end + highlight_if(display_name, @vaccination_record.vaccine_id_changed?) end def delivery_method_value diff --git a/app/controllers/api/reporting/totals_controller.rb b/app/controllers/api/reporting/totals_controller.rb index 10e8cb3476..e0911e7df0 100644 --- a/app/controllers/api/reporting/totals_controller.rb +++ b/app/controllers/api/reporting/totals_controller.rb @@ -14,13 +14,16 @@ class API::Reporting::TotalsController < API::Reporting::BaseController GROUPS = { local_authority: :patient_local_authority_code, year_group: :patient_year_group, - gender: :patient_gender + gender: :patient_gender, + school: %i[patient_school_urn patient_school_name] }.freeze GROUP_HEADERS = { patient_local_authority_code: "Local Authority", patient_year_group: "Year Group", - patient_gender: "Gender" + patient_gender: "Gender", + patient_school_urn: "School URN", + patient_school_name: "School Name" }.freeze METRIC_HEADERS = { @@ -99,15 +102,18 @@ def csv_headers(groups) headers end + def parse_groups + params[:group] + .to_s + .split(",") + .map { GROUPS[it.strip.to_sym] } + .compact + .flatten + .uniq + end + def render_format_csv - groups = - params[:group] - .to_s - .split(",") - .map { GROUPS[it.strip.to_sym] } - .compact - .flatten - .uniq + groups = parse_groups scope = @totals_scope scope = scope.group(groups).select(groups) if groups.any? @@ -117,6 +123,27 @@ def render_format_csv end def render_format_json + groups = parse_groups + + groups.any? ? render_grouped_json(groups) : render_totals_json + end + + def render_grouped_json(groups) + records = @totals_scope.group(groups).select(groups).with_aggregate_metrics + render json: records.map { grouped_record_json(it, groups) } + end + + def grouped_record_json(record, groups) + groups + .to_h { [it.to_s.delete_prefix("patient_").to_sym, record[it]] } + .merge( + cohort: record.cohort, + vaccinated: record.vaccinated, + not_vaccinated: record.not_vaccinated + ) + end + + def render_totals_json cohort = @totals_scope.cohort_count vaccinated = @totals_scope.vaccinated_count diff --git a/app/controllers/concerns/navigation_concern.rb b/app/controllers/concerns/navigation_concern.rb index 11f8f74e72..e6e509ad8f 100644 --- a/app/controllers/concerns/navigation_concern.rb +++ b/app/controllers/concerns/navigation_concern.rb @@ -59,14 +59,14 @@ def set_navigation_items @navigation_items << { title: t("imports.index.title_short"), path: imports_path, - count: @cached_counts.import_issues + count: (@cached_counts.import_issues if policy(%i[import issue]).index?) } end if current_team&.has_poc_only_access? @navigation_items << { title: I18n.t("teams.show.title_short"), - path: team_path + path: contact_details_team_path } end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 8bb918487c..6a5dcd45a2 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -92,7 +92,7 @@ def set_secondary_items @secondary_items << { title: I18n.t("teams.show.title"), - path: team_path, + path: contact_details_team_path, description: I18n.t("teams.show.description") } end diff --git a/app/controllers/draft_sessions_controller.rb b/app/controllers/draft_sessions_controller.rb index 1f2cd8ead1..93f9ca36c0 100644 --- a/app/controllers/draft_sessions_controller.rb +++ b/app/controllers/draft_sessions_controller.rb @@ -130,7 +130,7 @@ def set_catch_up_patients_vaccinated_percentage @draft_session .patient_locations .where(patient: { birth_academic_year: birth_academic_years }) - .includes(patient: :vaccination_statuses) + .includes(patient: :programme_statuses) .map(&:patient) total_count = catch_up_patients.count @@ -141,7 +141,7 @@ def set_catch_up_patients_vaccinated_percentage .programmes_for(patient:) .all? do |programme| if programme.is_catch_up?(year_group:) - patient.vaccination_status(programme:, academic_year:).vaccinated? + patient.programme_status(programme, academic_year:).vaccinated? else true end @@ -415,13 +415,13 @@ def catch_up_year_group_has_high_unvaccinated_count?(programme, year_group) @draft_session .patient_locations .where(patient: { birth_academic_year: }) - .includes(patient: :vaccination_statuses) + .includes(patient: :programme_statuses) .map(&:patient) total_count = catch_up_patients.count vaccinated_count = catch_up_patients.count do |patient| - patient.vaccination_status(programme:, academic_year:).vaccinated? + patient.programme_status(programme, academic_year:).vaccinated? end vaccinated_count < total_count / 2 diff --git a/app/controllers/draft_vaccination_records_controller.rb b/app/controllers/draft_vaccination_records_controller.rb index 2195bf39ed..b49b64ce1e 100644 --- a/app/controllers/draft_vaccination_records_controller.rb +++ b/app/controllers/draft_vaccination_records_controller.rb @@ -14,7 +14,16 @@ class DraftVaccinationRecordsController < ApplicationController include WizardControllerConcern before_action :validate_params, only: :update - before_action :set_batches, if: -> { current_step == :batch } + before_action :set_batches, + if: -> do + current_step == :batch && + !@draft_vaccination_record.bulk_upload_user_and_record? + end + before_action :set_vaccines, + if: -> do + current_step == :batch && + @draft_vaccination_record.bulk_upload_user_and_record? + end before_action :set_locations, if: -> { current_step == :location } before_action :set_supplied_by_users, if: -> { current_step == :supplier } before_action :set_back_link_path @@ -73,6 +82,20 @@ def validate_params @draft_vaccination_record.errors.add(:performed_at, :invalid) render_wizard nil, status: :unprocessable_content end + elsif current_step == :batch && + @draft_vaccination_record.bulk_upload_user_and_record? + validator = + DateParamsValidator.new( + field_name: :batch_expiry, + object: @draft_vaccination_record, + params: update_params + ) + + unless validator.date_params_valid? + @draft_vaccination_record.errors.add(:batch_expiry, :invalid) + set_vaccines + render_wizard nil, status: :unprocessable_content + end end end @@ -144,6 +167,8 @@ def handle_confirm NextDoseTriageFactory.call(vaccination_record: @vaccination_record) + PatientTeamUpdater.call(patient_scope: Patient.where(id: @patient.id)) + StatusUpdater.call(patient: @patient) if should_notify_parents @@ -174,7 +199,7 @@ def finish_wizard_path def update_params permitted_attributes = { - batch: %i[batch_id], + batch: %i[batch_id vaccine_id batch_name batch_expiry], confirm: @draft_vaccination_record.editing? ? [] : %i[notes], date_and_time: %i[performed_at], delivery: %i[delivery_site delivery_method], @@ -229,6 +254,10 @@ def set_steps self.steps = @draft_vaccination_record.wizard_steps end + def set_vaccines + @vaccines = @programme.vaccines.select(&:nivs_name) + end + def set_batches vaccines = vaccine_criteria.apply(@programme.vaccines) diff --git a/app/controllers/patients_controller.rb b/app/controllers/patients_controller.rb index 9e6cbf664a..0e0fa7a9a3 100644 --- a/app/controllers/patients_controller.rb +++ b/app/controllers/patients_controller.rb @@ -62,11 +62,18 @@ def pds_search_history end def invite_to_clinic - PatientLocation.find_or_create_by!( - patient: @patient, - location: current_team.generic_clinic, - academic_year: AcademicYear.pending - ) + ActiveRecord::Base.transaction do + PatientLocation.find_or_create_by!( + patient: @patient, + location: current_team.generic_clinic, + academic_year: AcademicYear.pending + ) + + PatientTeamUpdater.call( + patient_scope: Patient.where(id: @patient.id), + team_scope: Team.where(id: current_team.id) + ) + end redirect_to patient_path(@patient), flash: { diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb index bdc2c09ca9..e353c31922 100644 --- a/app/controllers/teams_controller.rb +++ b/app/controllers/teams_controller.rb @@ -2,8 +2,47 @@ class TeamsController < ApplicationController skip_after_action :verify_policy_scoped + before_action :set_team + before_action :set_schools, only: :schools + before_action :set_clinics, only: :clinics - def show + layout "full" + + def contact_details + end + + def sessions + end + + def schools + end + + def clinics + end + + private + + def set_team @team = authorize current_team end + + def set_schools + @schools = + @team + .schools + .joins(:team_locations) + .where(team_locations: { academic_year: AcademicYear.pending }) + .distinct + .order(:name) + end + + def set_clinics + @clinics = + @team + .community_clinics + .joins(:team_locations) + .where(team_locations: { academic_year: AcademicYear.pending }) + .distinct + .order(:name) + end end diff --git a/app/forms/batch_form.rb b/app/forms/batch_form.rb index f99a4e1f59..7ebc8b6d49 100644 --- a/app/forms/batch_form.rb +++ b/app/forms/batch_form.rb @@ -9,17 +9,7 @@ class BatchForm attribute :name, :string attribute :expiry, :date - NAME_FORMAT = /\A[A-Za-z0-9]+\z/ - - validates :name, - presence: true, - format: { - with: NAME_FORMAT - }, - length: { - minimum: 2, - maximum: 100 - } + validates :name, batch_name: true validates :expiry, comparison: { diff --git a/app/forms/triage_form.rb b/app/forms/triage_form.rb index 4e746b9be6..a9a293d032 100644 --- a/app/forms/triage_form.rb +++ b/app/forms/triage_form.rb @@ -94,14 +94,13 @@ def show_add_patient_specific_direction?(option) end def next_mmr_dose_date - vaccination_status = patient.vaccination_status(programme:, academic_year:) + programme_status = patient.programme_status(programme, academic_year:) - first_dose_date = - if vaccination_status.eligible? || vaccination_status.due? - vaccination_status.latest_date - end - - (first_dose_date || Date.current) + 28.days + if programme_status.cannot_vaccinate_delay_vaccination? + programme_status.date + elsif (first_dose_date = programme_status.date) + (first_dose_date + 28.days).to_date + end end private @@ -269,8 +268,8 @@ def associate_triage_with_vaccination_record(next_dose_delay_triage) def patient_eligible_for_additional_dose? next_dose = - patient.vaccination_status( - programme: programme, + patient.programme_status( + programme, academic_year: session.academic_year ).dose_sequence diff --git a/app/jobs/concerns/send_school_consent_notification_concern.rb b/app/jobs/concerns/send_school_consent_notification_concern.rb index 07a04f8cc0..f70935a4c3 100644 --- a/app/jobs/concerns/send_school_consent_notification_concern.rb +++ b/app/jobs/concerns/send_school_consent_notification_concern.rb @@ -11,7 +11,7 @@ def patient_programmes_eligible_for_notification(session:) session .patient_locations .includes( - patient: %i[consent_notifications consent_statuses vaccination_statuses] + patient: %i[consent_notifications consent_statuses programme_statuses] ) .find_each do |patient_location| patient = patient_location.patient @@ -33,8 +33,7 @@ def get_programmes_that_need_consent(patient:, session:, programmes:) academic_year = session.academic_year programmes.select do |programme| - patient.consent_status(programme:, academic_year:).no_response? && - patient.vaccination_status(programme:, academic_year:).eligible? + patient.programme_status(programme, academic_year:).needs_consent? end end diff --git a/app/jobs/send_clinic_initial_invitations_job.rb b/app/jobs/send_clinic_initial_invitations_job.rb index 2820dd6c88..4585abfc60 100644 --- a/app/jobs/send_clinic_initial_invitations_job.rb +++ b/app/jobs/send_clinic_initial_invitations_job.rb @@ -57,7 +57,7 @@ def should_send_notification?(patient:, team:, academic_year:, programmes:) return if already_invited programmes.any? do |programme| - !patient.vaccination_status(programme:, academic_year:).vaccinated? && + !patient.programme_status(programme, academic_year:).vaccinated? && !patient.consent_status(programme:, academic_year:).refused? end end diff --git a/app/jobs/send_clinic_subsequent_invitations_job.rb b/app/jobs/send_clinic_subsequent_invitations_job.rb index ffd5a269a7..d9741231aa 100644 --- a/app/jobs/send_clinic_subsequent_invitations_job.rb +++ b/app/jobs/send_clinic_subsequent_invitations_job.rb @@ -57,7 +57,7 @@ def should_send_notification?(patient:, team:, academic_year:, programmes:) return unless already_invited programmes.any? do |programme| - !patient.vaccination_status(programme:, academic_year:).vaccinated? && + !patient.programme_status(programme, academic_year:).vaccinated? && !patient.consent_status(programme:, academic_year:).refused? end end diff --git a/app/jobs/send_school_session_reminders_job.rb b/app/jobs/send_school_session_reminders_job.rb index b56df1307c..35dd8db06d 100644 --- a/app/jobs/send_school_session_reminders_job.rb +++ b/app/jobs/send_school_session_reminders_job.rb @@ -47,7 +47,7 @@ def should_send_notification?(patient:, session:) all_vaccinated = programmes.all? do |programme| - patient.vaccination_status(programme:, academic_year:).vaccinated? + patient.programme_status(programme, academic_year:).vaccinated? end return false if all_vaccinated diff --git a/app/lib/already_had_notification_sender.rb b/app/lib/already_had_notification_sender.rb index 465cd44030..acd6c71c94 100644 --- a/app/lib/already_had_notification_sender.rb +++ b/app/lib/already_had_notification_sender.rb @@ -58,7 +58,7 @@ def self.call(...) = new(...).call attr_reader :vaccination_record - delegate :patient, :programme, to: :vaccination_record + delegate :patient, :programme_type, to: :vaccination_record def academic_year = AcademicYear.current @@ -67,7 +67,7 @@ def other_vaccination_records end def would_still_be_vaccinated? - # We're not using the existing `Patient::VaccinationStatus` instance here + # We're not using the existing `Patient::ProgrammeStatus` instance here # because we want to know if the patient would still be vaccinated if we # took away the vaccination record in question, to know whether to send # the notification. @@ -76,7 +76,7 @@ def would_still_be_vaccinated? # although we're using the same status generator logic as elsewhere, we # don't need to pass in the consents and triage as an optimisation. StatusGenerator::Vaccination.new( - programme:, + programme_type:, academic_year:, patient:, vaccination_records: other_vaccination_records, diff --git a/app/lib/clinic_patient_locations_factory.rb b/app/lib/clinic_patient_locations_factory.rb index 2c277c7b27..ac7df5adfe 100644 --- a/app/lib/clinic_patient_locations_factory.rb +++ b/app/lib/clinic_patient_locations_factory.rb @@ -10,7 +10,7 @@ def create_patient_locations! PatientLocation.import!( patient_locations_to_create, on_duplicate_key_ignore: true - ).ids + ) PatientTeamUpdater.call( patient_scope: patients_in_school, diff --git a/app/lib/fhir_mapper/vaccination_record.rb b/app/lib/fhir_mapper/vaccination_record.rb index d1dff09bf3..9f79a05461 100644 --- a/app/lib/fhir_mapper/vaccination_record.rb +++ b/app/lib/fhir_mapper/vaccination_record.rb @@ -18,10 +18,10 @@ def initialize(vaccination_record) def fhir_record immunisation = FHIR::Immunization.new(id: nhs_immunisations_api_id) - if performed_by_user.present? - immunisation.contained << performed_by_user.fhir_practitioner( - reference_id: "Practitioner1" - ) + if performed_by.present? + immunisation.contained << FHIRMapper::User.new( + performed_by + ).fhir_practitioner(reference_id: "Practitioner1") end immunisation.contained << patient.fhir_record(reference_id: "Patient1") @@ -44,10 +44,12 @@ def fhir_record immunisation.site = fhir_site immunisation.route = fhir_route immunisation.doseQuantity = fhir_dose_quantity - immunisation.performer = [ - fhir_user_performer(reference_id: "Practitioner1"), - fhir_org_performer - ] + if performed_by.present? + immunisation.performer << fhir_user_performer( + reference_id: "Practitioner1" + ) + end + immunisation.performer << fhir_org_performer immunisation.reasonCode = [fhir_reason_code] immunisation.protocolApplied = [fhir_protocol_applied] diff --git a/app/lib/govuk_notify_personalisation.rb b/app/lib/govuk_notify_personalisation.rb index 711ba96f88..ac8d29ce68 100644 --- a/app/lib/govuk_notify_personalisation.rb +++ b/app/lib/govuk_notify_personalisation.rb @@ -199,10 +199,9 @@ def mmr_second_dose_message return unless patient return unless mmr_programme - vaccination_status = - patient.vaccination_status(programme: mmr_programme, academic_year:) + programme_status = patient.programme_status(mmr_programme, academic_year:) - return "" if vaccination_status.vaccinated? + return "" if programme_status.vaccinated? [ "## Your child still needs a second dose of the MMR vaccine", @@ -262,15 +261,16 @@ def next_mmr_dose_date return if patient.nil? return if mmr_programme.nil? - vaccination_status = - patient.vaccination_status(programme: mmr_programme, academic_year:) + programme_status = patient.programme_status(mmr_programme, academic_year:) - first_dose_date = - if vaccination_status.eligible? || vaccination_status.due? - vaccination_status.latest_date + date = + if programme_status.cannot_vaccinate_delay_vaccination? + programme_status.date + elsif (first_dose_date = programme_status.date) + (first_dose_date + 28.days).to_date end - (first_dose_date + 28.days).to_date.to_fs(:long) if first_dose_date + date.to_fs(:long) end def patient_eligible_for_additional_dose? @@ -280,7 +280,7 @@ def patient_eligible_for_additional_dose? next_dose = patient .reload - .vaccination_status(programme: mmr_programme, academic_year:) + .programme_status(mmr_programme, academic_year:) .dose_sequence next_dose == mmr_programme.maximum_dose_sequence diff --git a/app/lib/mavis_cli/clinics/add_to_team.rb b/app/lib/mavis_cli/clinics/add_to_team.rb index 68b3ebc98b..ced88305f3 100644 --- a/app/lib/mavis_cli/clinics/add_to_team.rb +++ b/app/lib/mavis_cli/clinics/add_to_team.rb @@ -5,46 +5,58 @@ module Clinics class AddToTeam < Dry::CLI::Command desc "Add an existing clinic to a team" - argument :workgroup, required: true, desc: "The ODS code of the team" - argument :subteam, required: true, desc: "The subteam of the team" + argument :team_workgroup, + required: true, + desc: "The workgroup of the team" + argument :subteam_name, required: true, desc: "The name of the subteam" argument :ods_codes, type: :array, required: true, - desc: "The ODS codes of the clinics" + desc: "The ODS code of the clinic" - def call(workgroup:, subteam:, ods_codes:, **) + def call(team_workgroup:, subteam_name:, ods_codes:, **) MavisCLI.load_rails - team = Team.find_by(workgroup:) + team = Team.find_by(workgroup: team_workgroup) academic_year = AcademicYear.pending if team.nil? - warn "Could not find team." + warn "Could not find team with workgroup #{team_workgroup}." return end - subteam = team.subteams.find_by(name: subteam) + subteam = team.subteams.find_by(name: subteam_name) + + if subteam.nil? + warn "Could not find subteam with name #{subteam_name}." + return + end ActiveRecord::Base.transaction do ods_codes.each do |ods_code| location = Location.clinic.find_by(ods_code:) if location.nil? - warn "Could not find location: #{ods_code}" + warn "Could not find clinic with ODS code #{ods_code}." next end if ( existing_team_locations = - location.team_locations.includes(:team).where(academic_year:) + location + .team_locations + .includes(:team, :subteam) + .where(academic_year:) ) existing_team_locations.each do |existing_team_location| - warn "#{ods_code} previously belonged to #{existing_team_location.name}" + warn "#{ods_code} previously belonged to #{existing_team_location.name}." end end location.attach_to_team!(team, academic_year:, subteam:) end + + PatientTeamUpdater.call(team_scope: Team.where(id: team.id)) end end end diff --git a/app/lib/mavis_cli/schools/add_to_team.rb b/app/lib/mavis_cli/schools/add_to_team.rb index f907cd854d..c92cbdf933 100644 --- a/app/lib/mavis_cli/schools/add_to_team.rb +++ b/app/lib/mavis_cli/schools/add_to_team.rb @@ -5,28 +5,35 @@ module Schools class AddToTeam < Dry::CLI::Command desc "Add an existing school to a team" - argument :workgroup, required: true, desc: "The ODS code of the team" - argument :subteam, required: true, desc: "The subteam of the team" + argument :team_workgroup, + required: true, + desc: "The workgroup of the team" + argument :subteam_name, required: true, desc: "The name of the subteam" argument :urns, type: :array, required: true, - desc: "The URN of the school" + desc: "The URN of the school (including site, if applicable)" option :programmes, type: :array, desc: "The programmes administered at the school" - def call(workgroup:, subteam:, urns:, programmes: [], **) + def call(team_workgroup:, subteam_name:, urns:, programmes: [], **) MavisCLI.load_rails - team = Team.find_by(workgroup:) + team = Team.find_by(workgroup: team_workgroup) if team.nil? - warn "Could not find team." + warn "Could not find team with workgroup #{team_workgroup}." return end - subteam = team.subteams.find_by(name: subteam) + subteam = team.subteams.find_by(name: subteam_name) + + if subteam.nil? + warn "Could not find subteam with name #{subteam_name}." + return + end programmes = (programmes.empty? ? team.programmes : Programme.find_all(programmes)) @@ -38,16 +45,19 @@ def call(workgroup:, subteam:, urns:, programmes: [], **) location = Location.school.find_by_urn_and_site(urn) if location.nil? - warn "Could not find location: #{urn}" + warn "Could not find school with URN #{urn}." next end if ( existing_team_locations = - location.team_locations.includes(:team).where(academic_year:) + location + .team_locations + .includes(:team, :subteam) + .where(academic_year:) ) existing_team_locations.each do |existing_team_location| - warn "#{ods_code} previously belonged to #{existing_team_location.name}" + warn "#{urn} previously belonged to #{existing_team_location.name}." end end @@ -58,6 +68,8 @@ def call(workgroup:, subteam:, urns:, programmes: [], **) academic_year: ) end + + PatientTeamUpdater.call(team_scope: Team.where(id: team.id)) end end end diff --git a/app/lib/mavis_cli/schools/create_site.rb b/app/lib/mavis_cli/schools/create_site.rb index 7af4e6335b..ea54a54d82 100644 --- a/app/lib/mavis_cli/schools/create_site.rb +++ b/app/lib/mavis_cli/schools/create_site.rb @@ -85,8 +85,8 @@ def call( ).subteam MavisCLI::Schools::AddToTeam.new.call( - workgroup: team.workgroup, - subteam: subteam.name, + team_workgroup: team.workgroup, + subteam_name: subteam.name, urns: [location.urn_and_site] ) diff --git a/app/lib/mavis_cli/schools/remove_from_team.rb b/app/lib/mavis_cli/schools/remove_from_team.rb index a1d1f55e10..80d57e51b3 100644 --- a/app/lib/mavis_cli/schools/remove_from_team.rb +++ b/app/lib/mavis_cli/schools/remove_from_team.rb @@ -43,15 +43,24 @@ def call(team_workgroup:, subteam_name:, urns:, academic_year: nil, **) location = Location.school.find_by_urn_and_site(urn) if location.nil? - warn "Could not find location with URN #{urn}" + warn "Could not find school with URN #{urn}" next end - team_location = - TeamLocation - .includes(:team) - .where(team:, academic_year:, subteam:, location:) - .sole + team_locations = + TeamLocation.includes(:team).where( + team:, + academic_year:, + subteam:, + location: + ) + + if team_locations.empty? + warn "Could not find team location for URN #{urn}" + next + else + team_location = team_locations.sole + end unless team_location.safe_to_destroy? warn "Location #{location.id} (URN: #{urn}) cannot be removed as it has associated records." diff --git a/app/lib/next_dose_triage_factory.rb b/app/lib/next_dose_triage_factory.rb index c6a1dffdf1..05370ad7b6 100644 --- a/app/lib/next_dose_triage_factory.rb +++ b/app/lib/next_dose_triage_factory.rb @@ -34,9 +34,7 @@ def should_create? return false if next_date.past? - status = patient.vaccination_status(programme:, academic_year:) - - !status.vaccinated? + !patient.programme_status(programme, academic_year:).vaccinated? end def next_date = vaccination_record.performed_at + 28.days diff --git a/app/lib/notifier/consent.rb b/app/lib/notifier/consent.rb index d4d54c8474..4e3b71a766 100644 --- a/app/lib/notifier/consent.rb +++ b/app/lib/notifier/consent.rb @@ -84,7 +84,7 @@ def patient_eligible_for_additional_dose?(session) next_dose = patient .reload - .vaccination_status(programme:, academic_year: session.academic_year) + .programme_status(programme, academic_year: session.academic_year) .dose_sequence next_dose == programme.maximum_dose_sequence diff --git a/app/lib/patient_archiver.rb b/app/lib/patient_archiver.rb index 1c7284b8a0..52e65e9d12 100644 --- a/app/lib/patient_archiver.rb +++ b/app/lib/patient_archiver.rb @@ -19,6 +19,8 @@ def call patient.clear_pending_sessions!(team:) destroy_school_moves! + + update_patient_teams! end end @@ -43,4 +45,11 @@ def destroy_school_moves! .where("team_locations.team_id = ?", team.id) .destroy_all end + + def update_patient_teams! + PatientTeamUpdater.call( + patient_scope: Patient.where(id: patient.id), + team_scope: Team.where(id: team.id) + ) + end end diff --git a/app/lib/patient_programme_status_resolver.rb b/app/lib/patient_programme_status_resolver.rb new file mode 100644 index 0000000000..020fbc7a9d --- /dev/null +++ b/app/lib/patient_programme_status_resolver.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +## +# This class can be used to generate a hash suitable for use by the +# `AppAttachedTagsComponent` used to render the various statuses of any +# particular patient, programme and academic year combination. +class PatientProgrammeStatusResolver + def initialize( + patient, + programme_type:, + academic_year:, + context_location_id: nil, + only_if_vaccinated: false + ) + @patient = patient + @programme_type = programme_type + @academic_year = academic_year + @context_location_id = context_location_id + @only_if_vaccinated = only_if_vaccinated + end + + def call + return false if only_if_vaccinated && !programme_status.vaccinated? + + { prefix:, text:, colour:, details_text: }.compact + end + + def self.call(...) = new(...).call + + private_class_method :new + + private + + attr_reader :patient, + :programme_type, + :academic_year, + :context_location_id, + :only_if_vaccinated + + def programme_status + @programme_status ||= + patient.programme_status(Programme.find(programme_type), academic_year:) + end + + def prefix = programme_status.programme.name + + def text + if programme_status.due? && (count = programme_status.dose_sequence) + "Due #{count.ordinalize} dose" + else + I18n.t(programme_status.status, scope: %i[status programme label]) + end + end + + def colour = + I18n.t(programme_status.status, scope: %i[status programme colour]) + + def details_text + text = + I18n.t( + programme_status.status, + scope: %i[status programme details], + default: nil + ) + + if programme_status.due? + translation_key = programme_status.vaccine_criteria.to_param + I18n.t(translation_key, scope: :vaccine_criteria).presence || text + elsif programme_status.cannot_vaccinate_delay_vaccination? + if (date = programme_status.date) + text + " until #{date.to_fs(:long)}" + else + text + end + elsif programme_status.vaccinated_fully? || + programme_status.cannot_vaccinate? + (date = programme_status.date) ? text + " on #{date.to_fs(:long)}" : text + else + text + end + end +end diff --git a/app/lib/patient_status_resolver.rb b/app/lib/patient_status_resolver.rb deleted file mode 100644 index 4b45736fd6..0000000000 --- a/app/lib/patient_status_resolver.rb +++ /dev/null @@ -1,121 +0,0 @@ -# frozen_string_literal: true - -## -# This class can be used to generate a hash suitable for use by the -# `AppAttachedTagsComponent` used to render the various statuses of any -# particular patient, programme and academic year combination. -class PatientStatusResolver - def initialize(patient, programme:, academic_year:, context_location_id: nil) - @patient = patient - @programme = programme - @academic_year = academic_year - @context_location_id = context_location_id - end - - def consent - status = - if consent_status.given? - vaccine_method = - triage_status.vaccine_method.presence || - consent_status.vaccine_methods.first - - without_gelatine = - triage_status.without_gelatine || consent_status.without_gelatine - - parts = [ - "given", - vaccine_method, - without_gelatine ? "without_gelatine" : nil, - without_gelatine && @programme.flu? ? "flu" : nil - ] - - parts.compact_blank.join("_") - else - consent_status.status - end - - tag_hash(status, context: :consent).merge( - prefix: consent_status.programme.name - ) - end - - def programme(only_if_vaccinated: false) - return if only_if_vaccinated && !programme_status.vaccinated? - - hash = tag_hash(programme_status.status, context: :programme) - - if programme_status.due? - if (count = programme_status.dose_sequence) - hash[:text] = "Due #{count.ordinalize} dose" - end - - translation_key = programme_status.vaccine_criteria.to_param - - if ( - details_text = I18n.t(translation_key, scope: :vaccine_criteria) - ).present? - hash[:details_text] = details_text - end - elsif programme_status.cannot_vaccinate_delay_vaccination? - if (date = programme_status.date) - hash[:details_text] += " until #{date.to_fs(:long)}" - end - elsif programme_status.vaccinated_fully? || - programme_status.cannot_vaccinate? - if (date = programme_status.date) - hash[:details_text] += " on #{date.to_fs(:long)}" - end - end - - hash.merge(prefix: programme_status.programme.name) - end - - def triage - status = - if triage_status.safe_to_vaccinate? - vaccine_method = triage_status.vaccine_method - without_gelatine = triage_status.without_gelatine - - parts = [ - "safe_to_vaccinate", - vaccine_method, - without_gelatine ? "without_gelatine" : nil, - without_gelatine && @programme.flu? ? "flu" : nil - ] - - parts.compact_blank.join("_") - else - triage_status.status - end - - tag_hash(status, context: :triage).merge( - prefix: consent_status.programme.name - ) - end - - private - - attr_reader :patient, :academic_year, :context_location_id - - def tag_hash(status, context:) - text = I18n.t(status, scope: [:status, context, :label]) - colour = I18n.t(status, scope: [:status, context, :colour]) - details_text = - I18n.t(status, scope: [:status, context, :details], default: nil) - { text:, colour:, details_text: }.compact - end - - def consent_status - @consent_status ||= - patient.consent_status(programme: @programme, academic_year:) - end - - def programme_status - @programme_status ||= patient.programme_status(@programme, academic_year:) - end - - def triage_status - @triage_status ||= - patient.triage_status(programme: @programme, academic_year:) - end -end diff --git a/app/lib/programme_grouper.rb b/app/lib/programme_grouper.rb index 9cec47efda..c8b518ec69 100644 --- a/app/lib/programme_grouper.rb +++ b/app/lib/programme_grouper.rb @@ -30,7 +30,7 @@ def group(object) if (value = GROUPS[key]) value else - raise UnsupportedProgramme, programme(object) + raise UnsupportedProgrammeType, key end end diff --git a/app/lib/reports/careplus_exporter.rb b/app/lib/reports/careplus_exporter.rb index 094f590ab2..e1b102b3a9 100644 --- a/app/lib/reports/careplus_exporter.rb +++ b/app/lib/reports/careplus_exporter.rb @@ -143,8 +143,8 @@ def rows(patient:, vaccination_records:) records.first.performed_at.strftime("%H:%M"), session.location.school? ? "SC" : "CL", # Venue Type session.location.dfe_number || team.careplus_venue_code, # Venue Code - "IN", # Staff Type - "LW5PM", # Staff Code + team.careplus_staff_type, + team.careplus_staff_code, "Y", # Attended; Did not attends do not get recorded on GP systems "", # Reason Not Attended; Always blank "", # Suspension End Date; Doesn't need to be used diff --git a/app/lib/stats/session.rb b/app/lib/stats/session.rb index 5136f28c86..d4237800bb 100644 --- a/app/lib/stats/session.rb +++ b/app/lib/stats/session.rb @@ -46,15 +46,6 @@ def self.call(...) = new(...).call delegate :academic_year, :location, :team, to: :session - def vaccinated_count - @vaccinated_count ||= - Patient::VaccinationStatus - .vaccinated - .for_programme(programme) - .where(patient_id: patient_ids, academic_year:) - .count - end - def due_statuses if programme.flu? %w[due_nasal due_injection] diff --git a/app/lib/status_generator/consent.rb b/app/lib/status_generator/consent.rb index e612d15912..73456eeeef 100644 --- a/app/lib/status_generator/consent.rb +++ b/app/lib/status_generator/consent.rb @@ -2,19 +2,23 @@ class StatusGenerator::Consent def initialize( - programme:, + programme_type:, academic_year:, patient:, consents:, vaccination_records: ) - @programme = programme + @programme_type = programme_type @academic_year = academic_year @patient = patient @consents = consents @vaccination_records = vaccination_records end + def programme + Programme.find(programme_type, disease_types:, patient:) + end + def status if status_should_be_given? :given @@ -47,7 +51,7 @@ def disease_types private - attr_reader :programme, + attr_reader :programme_type, :academic_year, :patient, :consents, @@ -59,7 +63,7 @@ def vaccinated? # in the consents and triage as an optimisation. @vaccinated ||= StatusGenerator::Vaccination.new( - programme:, + programme_type:, academic_year:, patient:, vaccination_records:, @@ -134,10 +138,6 @@ def parental_consents def latest_consents @latest_consents ||= - ConsentGrouper.call( - consents, - programme_type: programme.type, - academic_year: - ) + ConsentGrouper.call(consents, programme_type:, academic_year:) end end diff --git a/app/lib/status_generator/programme.rb b/app/lib/status_generator/programme.rb index 58d8fbdcd6..b1e1c5f511 100644 --- a/app/lib/status_generator/programme.rb +++ b/app/lib/status_generator/programme.rb @@ -9,7 +9,7 @@ class StatusGenerator::Programme # to already be sorted in reverse chronological order, meaning the most # recent item is at the beginning of the array. def initialize( - programme:, + programme_type:, academic_year:, patient:, patient_locations:, @@ -18,7 +18,7 @@ def initialize( attendance_record:, vaccination_records: ) - @programme = programme + @programme_type = programme_type @academic_year = academic_year @patient = patient @patient_locations = patient_locations @@ -28,6 +28,10 @@ def initialize( @vaccination_records = vaccination_records end + def programme + Programme.find(programme_type, disease_types:, patient:) + end + def status if should_be_vaccinated_already? :vaccinated_already @@ -68,12 +72,7 @@ def status end end - def dose_sequence - if triage_generator.status.in?(%i[safe_to_vaccinate not_required]) && - consent_generator.status == :given - vaccination_generator.dose_sequence - end - end + delegate :dose_sequence, to: :vaccination_generator def without_gelatine if vaccination_generator.status == :not_eligible || @@ -122,7 +121,7 @@ def location_id private - attr_reader :programme, + attr_reader :programme_type, :academic_year, :patient, :patient_locations, @@ -214,7 +213,7 @@ def should_be_needs_consent_request_not_scheduled? def consent_generator @consent_generator ||= StatusGenerator::Consent.new( - programme:, + programme_type:, academic_year:, patient:, consents:, @@ -225,7 +224,7 @@ def consent_generator def triage_generator @triage_generator ||= StatusGenerator::Triage.new( - programme:, + programme_type:, academic_year:, patient:, consents:, @@ -237,7 +236,7 @@ def triage_generator def vaccination_generator @vaccination_generator ||= StatusGenerator::Vaccination.new( - programme:, + programme_type:, academic_year:, patient:, patient_locations:, diff --git a/app/lib/status_generator/triage.rb b/app/lib/status_generator/triage.rb index 44c93d21b8..9c2228e25f 100644 --- a/app/lib/status_generator/triage.rb +++ b/app/lib/status_generator/triage.rb @@ -2,14 +2,14 @@ class StatusGenerator::Triage def initialize( - programme:, + programme_type:, academic_year:, patient:, consents:, triages:, vaccination_records: ) - @programme = programme + @programme_type = programme_type @academic_year = academic_year @patient = patient @consents = consents @@ -17,6 +17,10 @@ def initialize( @vaccination_records = vaccination_records end + def programme + Programme.find(programme_type, disease_types:, patient:) + end + def status if status_should_be_safe_to_vaccinate? :safe_to_vaccinate @@ -41,6 +45,8 @@ def without_gelatine latest_triage&.without_gelatine if status_should_be_safe_to_vaccinate? end + delegate :disease_types, to: :consent_generator + def delay_vaccination_until_date if status_should_be_delay_vaccination? latest_triage&.delay_vaccination_until @@ -66,22 +72,20 @@ def vaccination_history_requires_triage? private - attr_reader :programme, + attr_reader :programme_type, :academic_year, :patient, :consents, :triages, :vaccination_records - def programme_type = programme.type - def vaccinated? # We only care about whether the patient is vaccinated so although we're # using the same status generator logic as elsewhere we don't need to pass # in the consents and triage as an optimisation. @vaccinated ||= StatusGenerator::Vaccination.new( - programme:, + programme_type:, academic_year:, patient:, vaccination_records:, @@ -126,7 +130,7 @@ def status_should_be_required? def consent_generator @consent_generator ||= StatusGenerator::Consent.new( - programme:, + programme_type:, academic_year:, patient:, consents:, diff --git a/app/lib/status_generator/vaccination.rb b/app/lib/status_generator/vaccination.rb index 37c70e5102..a6c573f045 100644 --- a/app/lib/status_generator/vaccination.rb +++ b/app/lib/status_generator/vaccination.rb @@ -9,7 +9,7 @@ class StatusGenerator::Vaccination # to already be sorted in reverse chronological order, meaning the most # recent item is at the beginning of the array. def initialize( - programme:, + programme_type:, academic_year:, patient:, patient_locations:, @@ -18,7 +18,7 @@ def initialize( attendance_record:, vaccination_records: ) - @programme = programme + @programme_type = programme_type @academic_year = academic_year @patient = patient @patient_locations = patient_locations @@ -28,8 +28,8 @@ def initialize( @vaccination_records = vaccination_records.select do - it.patient_id == patient.id && it.programme_type == programme.type && - if programme.seasonal? + it.patient_id == patient.id && it.programme_type == programme_type && + if seasonal? it.academic_year == academic_year else it.academic_year <= academic_year @@ -37,6 +37,10 @@ def initialize( end end + def programme + Programme.find(programme_type, disease_types:, patient:) + end + def status if status_should_be_vaccinated? :vaccinated @@ -94,7 +98,7 @@ def latest_session_status private - attr_reader :programme, + attr_reader :programme_type, :academic_year, :patient, :patient_locations, @@ -141,7 +145,7 @@ def year_group = patient.year_group(academic_year:) def valid_vaccination_records @valid_vaccination_records ||= - if programme.seasonal? + if seasonal? vaccination_records.select { it.administered? || it.already_had? } else if ( @@ -152,14 +156,14 @@ def valid_vaccination_records administered_records = vaccination_records.select(&:administered?) - if programme.doubles? + if doubles? filter_doubles_vaccination_records(administered_records) - elsif programme.hpv? + elsif hpv? filter_hpv_vaccination_records(administered_records) - elsif programme.mmr? + elsif mmr? filter_mmr_vaccination_records(administered_records) else - raise UnsupportedProgramme, programme + raise UnsupportedProgrammeType, programme.type end end end @@ -211,11 +215,11 @@ def vaccinated_vaccination_record return already_had_record end - if programme.mmr? - if valid_vaccination_records.count >= programme.maximum_dose_sequence + if mmr? + if valid_vaccination_records.count >= maximum_dose_sequence valid_vaccination_records.first end - elsif programme.td_ipv? + elsif td_ipv? valid_vaccination_records.find do it.dose_sequence == 5 || (it.dose_sequence.nil? && it.sourced_from_service?) @@ -232,16 +236,31 @@ def is_eligible? .select { it.academic_year == academic_year } .any? do |patient_location| patient_location.location.location_programme_year_groups.any? do - it.programme_type == programme.type && + it.programme_type == programme_type && it.academic_year == academic_year && it.year_group == year_group end end end + Programme::TYPES.each do |type| + define_method("#{type}?") { programme_type == type } + end + + def doubles? = menacwy? || td_ipv? + + def seasonal? + @seasonal ||= Programme.find(programme_type).seasonal? + end + + def maximum_dose_sequence + @maximum_dose_sequence ||= + Programme.find(programme_type).maximum_dose_sequence + end + def consent_generator @consent_generator ||= StatusGenerator::Consent.new( - programme:, + programme_type:, academic_year:, patient:, consents:, @@ -252,7 +271,7 @@ def consent_generator def triage_generator @triage_generator ||= StatusGenerator::Triage.new( - programme:, + programme_type:, academic_year:, patient:, consents:, diff --git a/app/lib/status_updater.rb b/app/lib/status_updater.rb index 9015370bdc..efcc97a5ec 100644 --- a/app/lib/status_updater.rb +++ b/app/lib/status_updater.rb @@ -11,7 +11,6 @@ def call update_programme_statuses! update_registration_statuses! update_triage_statuses! - update_vaccination_statuses! end def self.call(...) = new(...).call @@ -134,43 +133,6 @@ def update_triage_statuses! end end - def update_vaccination_statuses! - Patient::VaccinationStatus.import!( - %i[patient_id programme_type academic_year], - patient_statuses_to_import, - on_duplicate_key_ignore: true - ) - - Patient::VaccinationStatus - .then { patient ? it.where(patient:) : it } - .where(academic_year: academic_years) - .includes( - :attendance_record, - :consents, - :patient, - :patient_locations, - :triages, - :vaccination_records - ) - .find_in_batches(batch_size: 10_000) do |batch| - batch.each(&:assign_status) - - Patient::VaccinationStatus.import!( - batch.select(&:changed?), - on_duplicate_key_update: { - conflict_target: [:id], - columns: %i[ - dose_sequence - latest_date - latest_location_id - latest_session_status - status - ] - } - ) - end - end - def patient_statuses_to_import @patient_statuses_to_import ||= Patient diff --git a/app/lib/unsupported_programme.rb b/app/lib/unsupported_programme.rb deleted file mode 100644 index 4227c092d4..0000000000 --- a/app/lib/unsupported_programme.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class UnsupportedProgramme < RuntimeError - def initialize(programme) - super("Unsupported programme: #{programme.name}") - end -end diff --git a/app/lib/unsupported_programme_type.rb b/app/lib/unsupported_programme_type.rb new file mode 100644 index 0000000000..61d5e25d50 --- /dev/null +++ b/app/lib/unsupported_programme_type.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UnsupportedProgrammeType < StandardError + def initialize(programme_type) + super("Unsupported programme type: #{programme_type}") + end +end diff --git a/app/models/archive_reason.rb b/app/models/archive_reason.rb index 712c0494e5..ec699368cd 100644 --- a/app/models/archive_reason.rb +++ b/app/models/archive_reason.rb @@ -28,8 +28,6 @@ # fk_rails_... (team_id => teams.id) # class ArchiveReason < ApplicationRecord - include UpdatesPatientTeam - self.inheritance_column = nil belongs_to :team diff --git a/app/models/cis2_info.rb b/app/models/cis2_info.rb index 194733efd1..a89adf3003 100644 --- a/app/models/cis2_info.rb +++ b/app/models/cis2_info.rb @@ -8,7 +8,7 @@ class CIS2Info SUPPORT_ROLE = "S8001:G8005:R8015" SUPPORT_WORKGROUP = "mavissupport" - SUPPORT_ORGANISATION = "Y90128" + SUPPORT_ORGANISATION = Settings.cis2.support_organisation ACCESS_SENSITIVE_FLAGGED_RECORDS_ACTIVITY_CODE = "B1611" INDEPENDENT_PRESCRIBING_ACTIVITY_CODE = "B0420" diff --git a/app/models/class_import.rb b/app/models/class_import.rb index 82c61e50d0..85d04d8926 100644 --- a/app/models/class_import.rb +++ b/app/models/class_import.rb @@ -97,9 +97,7 @@ def postprocess_rows! ) end - @imported_school_move_ids ||= [] - @imported_school_move_ids |= - SchoolMove.import!(school_moves, on_duplicate_key_ignore: true).ids + SchoolMove.import!(school_moves, on_duplicate_key_ignore: true) valid_changesets.update_all(status: :processed) if valid_changesets diff --git a/app/models/concerns/patient_import_concern.rb b/app/models/concerns/patient_import_concern.rb index 96db0cf443..1a289543ae 100644 --- a/app/models/concerns/patient_import_concern.rb +++ b/app/models/concerns/patient_import_concern.rb @@ -70,11 +70,13 @@ def import_school_moves(changesets, import) # the duplicates won't be persisted, so we can skip those school_move.confirm! if school_move.patient.persisted? end + school_move_import_records = importable_school_moves.to_a + SchoolMove.import!( school_move_import_records, on_duplicate_key_update: :all - ).ids + ) end def import_pds_search_results(changesets, import) diff --git a/app/models/concerns/updates_patient_team.rb b/app/models/concerns/updates_patient_team.rb deleted file mode 100644 index faae20c8da..0000000000 --- a/app/models/concerns/updates_patient_team.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module UpdatesPatientTeam - extend ActiveSupport::Concern - - included do - after_save :update_patient_team - after_destroy :update_patient_team - end - - private - - def update_patient_team - if should_update_patient_team? - PatientTeamUpdater.call( - patient_scope: patient_scope_for_update_patient_team, - team_scope: team_scope_for_update_patient_team - ) - end - end - - def should_update_patient_team? - try(:patient_id_previous_change).present? || - try(:team_id_previous_change).present? - end - - def patient_scope_for_update_patient_team - if (previous_change = try(:patient_id_previous_change)).present? - Patient.where(id: previous_change.compact) - end - end - - def team_scope_for_update_patient_team - if (previous_change = try(:team_id_previous_change)).present? - Team.where(id: previous_change.compact) - end - end -end diff --git a/app/models/draft_vaccination_record.rb b/app/models/draft_vaccination_record.rb index 30fa232b08..662d5fb473 100644 --- a/app/models/draft_vaccination_record.rb +++ b/app/models/draft_vaccination_record.rb @@ -10,6 +10,8 @@ class DraftVaccinationRecord include VaccinationRecordPerformedByConcern attribute :batch_id, :integer + attribute :batch_name, :string + attribute :batch_expiry, :date attribute :delivery_method, :string attribute :delivery_site, :string attribute :disease_types, array: true @@ -34,6 +36,7 @@ class DraftVaccinationRecord attribute :session_id, :integer attribute :source, :string attribute :supplied_by_user_id, :integer + attribute :vaccine_id, :integer def initialize(current_user:, **attributes) @current_user = current_user @@ -83,7 +86,11 @@ def wizard_steps end on_wizard_step :batch, exact: true do - validates :batch_id, presence: true + validates :batch_id, presence: true, unless: :bulk_upload_user_and_record? + + validates :vaccine_id, presence: true, if: :bulk_upload_user_and_record? + validates :batch_name, batch_name: true, if: :bulk_upload_user_and_record? + validates :batch_expiry, presence: true, if: :bulk_upload_user_and_record? end on_wizard_step :dose, exact: true do @@ -155,6 +162,17 @@ def already_had? alias_method :administered, :administered? def batch + if batch_expiry && batch_name && vaccine_id && bulk_upload_user_and_record? + return( + Batch.create_with(archived_at: Time.current).find_or_create_by!( + expiry: batch_expiry, + name: batch_name, + team_id: nil, + vaccine_id: vaccine_id + ) + ) + end + return nil if batch_id.nil? Batch.find(batch_id) end @@ -236,8 +254,6 @@ def delivery_method=(value) delegate :vaccine, to: :batch, allow_nil: true - delegate :id, to: :vaccine, prefix: true, allow_nil: true - def vaccine_id_changed? = batch_id_changed? def location_is_school @@ -283,7 +299,7 @@ def vaccine_method_matches_consent_and_triage? consent_generator = StatusGenerator::Consent.new( - programme:, + programme_type:, academic_year:, patient:, consents: patient.consents, @@ -292,7 +308,7 @@ def vaccine_method_matches_consent_and_triage? triage_generator = StatusGenerator::Triage.new( - programme:, + programme_type:, academic_year:, patient:, consents: patient.consents, @@ -331,6 +347,24 @@ def bulk_upload_user_and_record? sourced_from_bulk_upload? end + def read_from!(vaccination_record) + self.batch_name = vaccination_record.batch&.name + self.batch_expiry = vaccination_record.batch&.expiry + self.vaccine_id = vaccination_record.vaccine&.id + + super(vaccination_record) + end + + def write_to!(vaccination_record) + super(vaccination_record) + + if batch_expiry && batch_name && vaccine_id && bulk_upload_user_and_record? + vaccination_record.batch_id = batch&.id + end + + vaccination_record.vaccine_id = batch&.vaccine_id + end + private def readable_attribute_names diff --git a/app/models/immunisation_import.rb b/app/models/immunisation_import.rb index 0217d67e0a..9d8211a25d 100644 --- a/app/models/immunisation_import.rb +++ b/app/models/immunisation_import.rb @@ -76,13 +76,6 @@ def process_row(row) count_column_to_increment = count_column(vaccination_record) return count_column_to_increment unless vaccination_record - # Instead of saving individually, we'll collect the records - @vaccination_records_batch ||= Set.new - @batches_batch ||= Set.new - @patients_batch ||= Set.new - @patient_locations_batch ||= Set.new - @archive_reasons_batch ||= Set.new - @vaccination_records_batch.add(vaccination_record) if (batch = vaccination_record.batch) @batches_batch.add(batch) @@ -103,6 +96,12 @@ def process_row(row) def process_import! counts = count_columns.index_with(0) + @vaccination_records_batch = Set.new + @batches_batch = Set.new + @patients_batch = Set.new + @patient_locations_batch = Set.new + @archive_reasons_batch = Set.new + ActiveRecord::Base.transaction do rows.each do |row| count_column_to_increment = process_row(row) diff --git a/app/models/immunisation_import_row.rb b/app/models/immunisation_import_row.rb index afaf522611..daed9f5b37 100644 --- a/app/models/immunisation_import_row.rb +++ b/app/models/immunisation_import_row.rb @@ -173,7 +173,11 @@ def to_vaccination_record } vaccination_record = - if uuid.present? + if bulk? + VaccinationRecord.find_or_initialize_by( + attributes.merge(attributes_to_stage_if_already_exists) + ) + elsif uuid.present? VaccinationRecord .find_by!(uuid: uuid.to_s) .tap { it.stage_changes(attributes) } @@ -630,7 +634,7 @@ def validate_batch_name errors.add(batch_name.header, "must be at most 100 characters long") elsif batch_name.to_s.length < 2 errors.add(batch_name.header, "must be at least 2 characters long") - elsif batch_name.to_s !~ BatchForm::NAME_FORMAT + elsif batch_name.to_s !~ BatchNameValidator::FORMAT errors.add(batch_name.header, "must be only letters and numbers") end elsif offline_recording? || bulk? diff --git a/app/models/onboarding.rb b/app/models/onboarding.rb index a3c7d3b8af..d2f5b2bf53 100644 --- a/app/models/onboarding.rb +++ b/app/models/onboarding.rb @@ -17,6 +17,8 @@ class Onboarding ORGANISATION_ATTRIBUTES = %i[ods_code].freeze TEAM_ATTRIBUTES = %i[ + careplus_staff_code + careplus_staff_type careplus_venue_code days_before_consent_reminders days_before_consent_requests @@ -223,6 +225,8 @@ def save!(include_previous_academic_year: false) academic_years.each do |academic_year| GenericClinicFactory.call(team:, academic_year:) end + + PatientTeamUpdater.call(team_scope: Team.where(id: team.id)) end end diff --git a/app/models/patient.rb b/app/models/patient.rb index 1450141321..4985b36a2a 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -86,7 +86,6 @@ class Patient < ApplicationRecord has_many :triage_statuses has_many :triages has_many :vaccination_records, -> { kept } - has_many :vaccination_statuses has_many :locations, through: :patient_locations has_many :parents, through: :parent_relationships @@ -155,12 +154,7 @@ class Patient < ApplicationRecord scope :includes_statuses, -> do - includes( - :consent_statuses, - :programme_statuses, - :triage_statuses, - vaccination_statuses: :latest_location - ) + includes(:consent_statuses, :programme_statuses, :triage_statuses) end scope :has_vaccination_records_dont_notify_parents, @@ -407,8 +401,8 @@ class Patient < ApplicationRecord return self if location.generic_clinic? && programme.seasonal? - vaccinated_statuses = - Patient::VaccinationStatus + programme_statuses = + Patient::ProgrammeStatus .select("1") .where("patient_id = patients.id") .for_programme(programme) @@ -416,18 +410,18 @@ class Patient < ApplicationRecord not_eligible_criteria = if location.generic_clinic? - vaccinated_statuses.where(academic_year: academic_year - 1) + programme_statuses.where(academic_year: academic_year - 1) else scope = - vaccinated_statuses.where(academic_year:).where( - "latest_location_id IS NULL OR latest_location_id != ?", + programme_statuses.where(academic_year:).where( + "location_id IS NULL OR location_id != ?", location.id ) unless programme.seasonal? scope = scope.or( - vaccinated_statuses.where(academic_year: academic_year - 1) + programme_statuses.where(academic_year: academic_year - 1) ) end @@ -618,10 +612,6 @@ def triage_status(programme:, academic_year:) patient_status(triage_statuses, programme:, academic_year:) end - def vaccination_status(programme:, academic_year:) - patient_status(vaccination_statuses, programme:, academic_year:) - end - def has_patient_specific_direction?(team:, **kwargs) patient_specific_directions.not_invalidated.where(team:, **kwargs).exists? end @@ -819,7 +809,7 @@ def archive_due_to_deceased! conflict_target: %i[team_id patient_id], columns: %i[type] } - ).ids + ) PatientTeamUpdater.call( patient_scope: Patient.where(id:), diff --git a/app/models/patient/consent_status.rb b/app/models/patient/consent_status.rb index a031699f7a..772a9f71e8 100644 --- a/app/models/patient/consent_status.rb +++ b/app/models/patient/consent_status.rb @@ -58,7 +58,7 @@ def vaccine_method_nasal? = vaccine_methods.include?("nasal") def generator @generator ||= StatusGenerator::Consent.new( - programme:, + programme_type:, academic_year:, patient:, consents:, diff --git a/app/models/patient/programme_status.rb b/app/models/patient/programme_status.rb index 7a684c9ec3..b838ede8b0 100644 --- a/app/models/patient/programme_status.rb +++ b/app/models/patient/programme_status.rb @@ -123,7 +123,7 @@ class Patient::ProgrammeStatus < ApplicationRecord scope :cannot_vaccinate, -> { where(status: CANNOT_VACCINATE_STATUSES.keys) } - scope :fully_vaccinated, -> { where(status: VACCINATED_STATUSES.keys) } + scope :vaccinated, -> { where(status: VACCINATED_STATUSES.keys) } def needs_consent? = status.in?(NEEDS_CONSENT_STATUSES.keys) @@ -152,7 +152,7 @@ def vaccine_criteria = VaccineCriteria.from_programme_status(self) def generator @generator ||= StatusGenerator::Programme.new( - programme:, + programme_type:, academic_year:, patient:, patient_locations:, diff --git a/app/models/patient/triage_status.rb b/app/models/patient/triage_status.rb index 5df2db6736..564ff3e6a1 100644 --- a/app/models/patient/triage_status.rb +++ b/app/models/patient/triage_status.rb @@ -73,7 +73,7 @@ def assign_status def generator @generator ||= StatusGenerator::Triage.new( - programme:, + programme_type:, academic_year:, patient:, consents:, diff --git a/app/models/patient/vaccination_status.rb b/app/models/patient/vaccination_status.rb deleted file mode 100644 index f74642958e..0000000000 --- a/app/models/patient/vaccination_status.rb +++ /dev/null @@ -1,92 +0,0 @@ -# frozen_string_literal: true - -# == Schema Information -# -# Table name: patient_vaccination_statuses -# -# id :bigint not null, primary key -# academic_year :integer not null -# dose_sequence :integer -# latest_date :date -# latest_session_status :integer -# programme_type :enum not null -# status :integer default("not_eligible"), not null -# latest_location_id :bigint -# patient_id :bigint not null -# -# Indexes -# -# idx_on_academic_year_patient_id_9c400fc863 (academic_year,patient_id) -# idx_on_patient_id_programme_type_academic_year_962639d2ac (patient_id,programme_type,academic_year) UNIQUE -# index_patient_vaccination_statuses_on_latest_location_id (latest_location_id) -# index_patient_vaccination_statuses_on_status (status) -# -# Foreign Keys -# -# fk_rails_... (latest_location_id => locations.id) -# fk_rails_... (patient_id => patients.id) ON DELETE => cascade -# -class Patient::VaccinationStatus < ApplicationRecord - include BelongsToProgramme - - belongs_to :patient - - belongs_to :latest_location, class_name: "Location", optional: true - - has_many :patient_locations, - -> { includes(location: :location_programme_year_groups) }, - through: :patient - - has_many :consents, - -> { not_invalidated.response_provided.includes(:parent, :patient) }, - through: :patient - - has_many :triages, - -> { not_invalidated.order(created_at: :desc) }, - through: :patient - - has_many :vaccination_records, - -> { kept.order(performed_at: :desc) }, - through: :patient - - has_one :attendance_record, - -> { today }, - through: :patient, - source: :attendance_records - - enum :status, - { not_eligible: 0, eligible: 1, due: 2, vaccinated: 3 }, - default: :not_eligible, - validate: true - - enum :latest_session_status, - { refused: 0, absent: 1, unwell: 2, contraindicated: 3, already_had: 4 }, - prefix: true, - validate: { - allow_nil: true - } - - def assign_status - self.status = generator.status - self.dose_sequence = generator.dose_sequence - self.latest_date = generator.latest_date - self.latest_location_id = generator.latest_location_id - self.latest_session_status = generator.latest_session_status - end - - private - - def generator - @generator ||= - StatusGenerator::Vaccination.new( - programme:, - academic_year:, - patient:, - patient_locations:, - consents:, - triages:, - attendance_record:, - vaccination_records: - ) - end -end diff --git a/app/models/patient_location.rb b/app/models/patient_location.rb index 4ba19b8dbc..19dbecefc3 100644 --- a/app/models/patient_location.rb +++ b/app/models/patient_location.rb @@ -25,8 +25,6 @@ # class PatientLocation < ApplicationRecord - include UpdatesPatientTeam - audited associated_with: :patient has_associated_audits diff --git a/app/models/reporting_api/total.rb b/app/models/reporting_api/total.rb index 73de303a90..623a023376 100644 --- a/app/models/reporting_api/total.rb +++ b/app/models/reporting_api/total.rb @@ -11,6 +11,8 @@ # patient_gender :text # patient_local_authority_code :string # patient_school_local_authority_code :string +# patient_school_name :text +# patient_school_urn :string # patient_year_group :integer # programme_type :enum # status :integer diff --git a/app/models/school_move.rb b/app/models/school_move.rb index b961489a5d..5efc974eb4 100644 --- a/app/models/school_move.rb +++ b/app/models/school_move.rb @@ -30,7 +30,6 @@ class SchoolMove < ApplicationRecord include Schoolable include SchoolMovesHelper - include UpdatesPatientTeam audited associated_with: :patient diff --git a/app/models/team.rb b/app/models/team.rb index 835a73ac08..e6c2d4ece8 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -5,7 +5,9 @@ # Table name: teams # # id :bigint not null, primary key -# careplus_venue_code :string not null +# careplus_staff_code :string +# careplus_staff_type :string +# careplus_venue_code :string # days_before_consent_reminders :integer default(7), not null # days_before_consent_requests :integer default(21), not null # days_before_invitations :integer default(21), not null @@ -81,7 +83,6 @@ class Team < ApplicationRecord prefix: "has", suffix: "access" - validates :careplus_venue_code, presence: true validates :email, notify_safe_email: true validates :name, presence: true, uniqueness: true validates :phone, presence: true, phone: true @@ -100,4 +101,8 @@ def year_groups(academic_year: nil) .where(location_year_group: { academic_year: }) .pluck_year_groups end + + def careplus_enabled? = + careplus_staff_code.present? && careplus_staff_type.present? && + careplus_venue_code.present? end diff --git a/app/models/team_location.rb b/app/models/team_location.rb index d59ee0e47f..ad31a1aeb1 100644 --- a/app/models/team_location.rb +++ b/app/models/team_location.rb @@ -27,8 +27,6 @@ # class TeamLocation < ApplicationRecord - include UpdatesPatientTeam - audited associated_with: :team has_associated_audits diff --git a/app/models/vaccination_record.rb b/app/models/vaccination_record.rb index cbd0073bc2..02b0498163 100644 --- a/app/models/vaccination_record.rb +++ b/app/models/vaccination_record.rb @@ -81,7 +81,6 @@ class VaccinationRecord < ApplicationRecord include HasDoseVolume include Notable include PendingChangesConcern - include UpdatesPatientTeam include VaccinationRecordPerformedByConcern include VaccinationRecordSyncToNHSImmunisationsAPIConcern diff --git a/app/models/vaccination_report.rb b/app/models/vaccination_report.rb index 76d2880d43..172618f205 100644 --- a/app/models/vaccination_report.rb +++ b/app/models/vaccination_report.rb @@ -4,8 +4,6 @@ class VaccinationReport include RequestSessionPersistable include WizardStepConcern - FILE_FORMATS = %w[mavis careplus systm_one].freeze - attribute :date_from, :date attribute :date_to, :date attribute :file_format, :string @@ -22,7 +20,7 @@ def wizard_steps end on_wizard_step :file_format, exact: true do - validates :file_format, inclusion: FILE_FORMATS + validates :file_format, inclusion: { in: :file_formats } end validates :programme_type, @@ -30,7 +28,7 @@ def wizard_steps :file_format, presence: true, on: :single_page - validates :file_format, inclusion: { in: FILE_FORMATS }, on: :single_page + validates :file_format, inclusion: { in: :file_formats }, on: :single_page def programme Programme.find(programme_type) if programme_type @@ -59,6 +57,15 @@ def csv_filename "#{programme.name} - #{file_format} - #{from_str} - #{to_str}.csv" end + def file_formats + common_file_formats = %w[mavis systm_one] + if @current_user.selected_team.careplus_enabled? + common_file_formats + ["careplus"] + else + common_file_formats + end + end + private def exporter_class diff --git a/app/policies/attendance_record_policy.rb b/app/policies/attendance_record_policy.rb index 5392211d4c..8e0c05c2e6 100644 --- a/app/policies/attendance_record_policy.rb +++ b/app/policies/attendance_record_policy.rb @@ -23,7 +23,7 @@ def already_vaccinated? session .programmes_for(patient:) .all? do |programme| - patient.vaccination_status(programme:, academic_year:).vaccinated? + patient.programme_status(programme, academic_year:).vaccinated? end end diff --git a/app/policies/import/issue_policy.rb b/app/policies/import/issue_policy.rb index 29a9323cb8..433f4a3ed4 100644 --- a/app/policies/import/issue_policy.rb +++ b/app/policies/import/issue_policy.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true class Import::IssuePolicy < ApplicationPolicy - def index? = true + def index? = !team.has_upload_only_access? - def create? = true + def create? = !team.has_upload_only_access? - def show? = true + def show? = !team.has_upload_only_access? - def update? = true + def update? = !team.has_upload_only_access? end diff --git a/app/policies/team_policy.rb b/app/policies/team_policy.rb index 95c4c8b5de..8b7aaf6c8c 100644 --- a/app/policies/team_policy.rb +++ b/app/policies/team_policy.rb @@ -2,14 +2,16 @@ class TeamPolicy < ApplicationPolicy def index? = false - def create? = false + def update? = false + def destroy? = false def show? = team.has_poc_only_access? && record == team - def update? = false - - def destroy? = false + alias_method :contact_details?, :show? + alias_method :schools?, :show? + alias_method :clinics?, :show? + alias_method :sessions?, :show? class Scope < ApplicationPolicy::Scope def resolve = scope.where(id: team.id) diff --git a/app/policies/vaccination_record_policy.rb b/app/policies/vaccination_record_policy.rb index 605f917c71..a4f59db86e 100644 --- a/app/policies/vaccination_record_policy.rb +++ b/app/policies/vaccination_record_policy.rb @@ -18,12 +18,8 @@ def create? def show? = true def record_already_vaccinated? - return unless user.is_nurse? || user.is_prescriber? - return if session.today? - - vaccination_status = patient.vaccination_status(programme:, academic_year:) - - vaccination_status.not_eligible? || vaccination_status.eligible? + (user.is_nurse? || user.is_prescriber?) && !session.today? && + !patient.programme_status(programme, academic_year:).vaccinated? end def update? diff --git a/app/validators/batch_name_validator.rb b/app/validators/batch_name_validator.rb new file mode 100644 index 0000000000..4d911b4953 --- /dev/null +++ b/app/validators/batch_name_validator.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class BatchNameValidator < ActiveModel::EachValidator + FORMAT = /\A[A-Za-z0-9]+\z/ + MIN_LENGTH = 2 + MAX_LENGTH = 100 + + def validate_each(record, attribute, value) + if value.blank? + record.errors.add(attribute, :blank) + elsif value.length < MIN_LENGTH + record.errors.add(attribute, :too_short, count: MIN_LENGTH) + elsif value.length > MAX_LENGTH + record.errors.add(attribute, :too_long, count: MAX_LENGTH) + elsif value !~ FORMAT + record.errors.add(attribute, :invalid) + end + end +end diff --git a/app/views/draft_vaccination_records/batch.html.erb b/app/views/draft_vaccination_records/batch.html.erb index 0e5eb7ea3e..3bb7a1d8e1 100644 --- a/app/views/draft_vaccination_records/batch.html.erb +++ b/app/views/draft_vaccination_records/batch.html.erb @@ -7,6 +7,25 @@ <%= form_with model: @draft_vaccination_record, url: wizard_path, method: :put do |f| %> <%= f.govuk_error_summary %> +<% if @draft_vaccination_record.bulk_upload_user_and_record? %> + + <%= @patient.full_name %> + <%= h1 "Which vaccine and batch did you use?" %> + + <%= f.govuk_radio_buttons_fieldset :vaccine_id, + legend: { text: "Vaccine", size: "m" } do %> + <% @vaccines.each do |vaccine| %> + <%= f.govuk_radio_button :vaccine_id, vaccine.id, label: { text: vaccine.nivs_name } %> + <% end %> + <% end %> + + <%= f.govuk_text_field :batch_name, + label: { text: "Batch number", size: "m" }, width: 10, class: "nhsuk-input--code" %> + + <%= f.govuk_date_field :batch_expiry, + legend: { text: "Batch expiry date", size: "m" }, + hint: { text: "For example, 27 10 2025" } %> +<% else %> <%= f.govuk_radio_buttons_fieldset :batch_id, caption: { text: @patient.full_name, size: "l" }, legend: { size: "l", tag: "h1", text: "Which batch did you use?" } do %> @@ -31,6 +50,7 @@ <% end %> <% end %> <% end %> +<% end %> <%= f.govuk_submit "Continue" %> <% end %> diff --git a/app/views/imports/_header.html.erb b/app/views/imports/_header.html.erb index 61deda8769..7934513d10 100644 --- a/app/views/imports/_header.html.erb +++ b/app/views/imports/_header.html.erb @@ -1,18 +1,19 @@ <%= h1 t("imports.index.title"), size: "xl" %> -<% if current_team.has_upload_only_access? %> -

- Use this page to upload and import vaccination records. -

-<% else %> -

+

+ <% if policy(ClassImport).new? && policy(CohortImport).new? %> Use this page to upload and import child, class list and vaccination records. -

-<% end %> + <% else %> + Use this page to upload and import vaccination records. + <% end %> +

After import, files move to the Completed imports tab. - Any close matches to resolve will appear in the Issues tab. + + <% if policy(%i[import issue]).index? %> + Any close matches to resolve will appear in the Issues tab. + <% end %>

diff --git a/app/views/parent_relationships/_fields.html.erb b/app/views/parent_relationships/_fields.html.erb new file mode 100644 index 0000000000..d2df7a5a4d --- /dev/null +++ b/app/views/parent_relationships/_fields.html.erb @@ -0,0 +1,38 @@ +<%= f.fields_for :parent do |parent_f| %> + <%= parent_f.govuk_text_field :full_name, label: { text: "Name" } %> +<% end %> + +<%= f.govuk_radio_buttons_fieldset :type, legend: { text: "Relationship to child", size: "s" } do %> + <%= f.govuk_radio_button :type, :mother, label: { text: "Mum" }, link_errors: true %> + <%= f.govuk_radio_button :type, :father, label: { text: "Dad" } %> + <%= f.govuk_radio_button :type, :guardian, label: { text: "Guardian" } %> + <%= f.govuk_radio_button :type, :other, label: { text: "Other" } do %> + <%= f.govuk_text_field :other_name, label: { text: "Relationship to the child" }, hint: { text: "For example, carer" } %> + <% end %> +<% end %> + +<%= f.fields_for :parent do |parent_f| %> + <%= parent_f.govuk_text_field :email, label: { text: "Email address" } %> + <%= parent_f.govuk_text_field :phone, label: { text: "Phone number" } %> + + <%= parent_f.govuk_check_boxes_fieldset :phone_receive_updates, multiple: false, legend: nil do %> + <%= parent_f.govuk_check_box :phone_receive_updates, 1, 0, multiple: false, link_errors: true, label: { text: "Get updates by text message" } %> + <% end %> + + <%= parent_f.govuk_radio_buttons_fieldset :contact_method_type, + legend: { text: "Does the parent have any specific needs?", size: "s" } do %> + <%= parent_f.govuk_radio_button :contact_method_type, "text", + label: { text: "They can only receive text messages" }, + link_errors: true %> + <%= parent_f.govuk_radio_button :contact_method_type, "voice", + label: { text: "They can only receive voice calls" } %> + <%= parent_f.govuk_radio_button :contact_method_type, "other", + label: { text: "Other" } do %> + <%= parent_f.govuk_text_area :contact_method_other_details, + label: { text: "Give details" } %> + <% end %> + <%= parent_f.govuk_radio_divider %> + <%= parent_f.govuk_radio_button :contact_method_type, "any", + label: { text: "They do not have specific needs" } %> + <% end %> +<% end %> diff --git a/app/views/parent_relationships/edit.html.erb b/app/views/parent_relationships/edit.html.erb index bfe3fb8d92..195e86fbf3 100644 --- a/app/views/parent_relationships/edit.html.erb +++ b/app/views/parent_relationships/edit.html.erb @@ -11,44 +11,7 @@ <%= page_title %> <% end %> - <%= f.fields_for :parent do |parent_f| %> - <%= parent_f.govuk_text_field :full_name, label: { text: "Name" } %> - <% end %> - - <%= f.govuk_radio_buttons_fieldset :type, legend: { text: "Relationship to child", size: "s" } do %> - <%= f.govuk_radio_button :type, :mother, label: { text: "Mum" }, link_errors: true %> - <%= f.govuk_radio_button :type, :father, label: { text: "Dad" } %> - <%= f.govuk_radio_button :type, :guardian, label: { text: "Guardian" } %> - <%= f.govuk_radio_button :type, :other, label: { text: "Other" } do %> - <%= f.govuk_text_field :other_name, label: { text: "Relationship to the child" }, hint: { text: "For example, carer" } %> - <% end %> - <% end %> - - <%= f.fields_for :parent do |parent_f| %> - <%= parent_f.govuk_text_field :email, label: { text: "Email address" } %> - <%= parent_f.govuk_text_field :phone, label: { text: "Phone number" } %> - - <%= parent_f.govuk_check_boxes_fieldset :phone_receive_updates, multiple: false, legend: nil do %> - <%= parent_f.govuk_check_box :phone_receive_updates, 1, 0, multiple: false, link_errors: true, label: { text: "Get updates by text message" } %> - <% end %> - - <%= parent_f.govuk_radio_buttons_fieldset :contact_method_type, - legend: { text: "Does the parent have any specific needs?", size: "s" } do %> - <%= parent_f.govuk_radio_button :contact_method_type, "text", - label: { text: "They can only receive text messages" }, - link_errors: true %> - <%= parent_f.govuk_radio_button :contact_method_type, "voice", - label: { text: "They can only receive voice calls" } %> - <%= parent_f.govuk_radio_button :contact_method_type, "other", - label: { text: "Other" } do %> - <%= parent_f.govuk_text_area :contact_method_other_details, - label: { text: "Give details" } %> - <% end %> - <%= parent_f.govuk_radio_divider %> - <%= parent_f.govuk_radio_button :contact_method_type, "any", - label: { text: "They do not have specific needs" } %> - <% end %> - <% end %> + <%= render "fields", f: %> <%= f.govuk_submit "Continue" %> <% end %> diff --git a/app/views/parent_relationships/new.html.erb b/app/views/parent_relationships/new.html.erb index ae78909638..67b742e01e 100644 --- a/app/views/parent_relationships/new.html.erb +++ b/app/views/parent_relationships/new.html.erb @@ -14,44 +14,7 @@ <%= page_title %> <% end %> - <%= f.fields_for :parent do |parent_f| %> - <%= parent_f.govuk_text_field :full_name, label: { text: "Name" } %> - <% end %> - - <%= f.govuk_radio_buttons_fieldset :type, legend: { text: "Relationship to child", size: "s" } do %> - <%= f.govuk_radio_button :type, :mother, label: { text: "Mum" }, link_errors: true %> - <%= f.govuk_radio_button :type, :father, label: { text: "Dad" } %> - <%= f.govuk_radio_button :type, :guardian, label: { text: "Guardian" } %> - <%= f.govuk_radio_button :type, :other, label: { text: "Other" } do %> - <%= f.govuk_text_field :other_name, label: { text: "Relationship to the child" }, hint: { text: "For example, carer" } %> - <% end %> - <% end %> - - <%= f.fields_for :parent do |parent_f| %> - <%= parent_f.govuk_text_field :email, label: { text: "Email address" } %> - <%= parent_f.govuk_text_field :phone, label: { text: "Phone number" } %> - - <%= parent_f.govuk_check_boxes_fieldset :phone_receive_updates, multiple: false, legend: nil do %> - <%= parent_f.govuk_check_box :phone_receive_updates, 1, 0, multiple: false, link_errors: true, label: { text: "Get updates by text message" } %> - <% end %> - - <%= parent_f.govuk_radio_buttons_fieldset :contact_method_type, - legend: { text: "Does the parent have any specific needs?", size: "s" } do %> - <%= parent_f.govuk_radio_button :contact_method_type, "text", - label: { text: "They can only receive text messages" }, - link_errors: true %> - <%= parent_f.govuk_radio_button :contact_method_type, "voice", - label: { text: "They can only receive voice calls" } %> - <%= parent_f.govuk_radio_button :contact_method_type, "other", - label: { text: "Other" } do %> - <%= parent_f.govuk_text_area :contact_method_other_details, - label: { text: "Give details" } %> - <% end %> - <%= parent_f.govuk_radio_divider %> - <%= parent_f.govuk_radio_button :contact_method_type, "any", - label: { text: "They do not have specific needs" } %> - <% end %> - <% end %> + <%= render "fields", f: %> <%= f.govuk_submit "Save" %> -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/patient_sessions/_header.html.erb b/app/views/patient_sessions/_header.html.erb index fd3767522f..a5eba5732e 100644 --- a/app/views/patient_sessions/_header.html.erb +++ b/app/views/patient_sessions/_header.html.erb @@ -69,7 +69,7 @@ href: session_patient_programme_path(@session, @patient, programme, return_to: params[:return_to]), text: programme.name, selected: @programme == programme, - ticked: @patient.vaccination_status(programme:, academic_year: @academic_year).vaccinated?, + ticked: @patient.programme_status(programme, academic_year: @academic_year).vaccinated?, ) end diff --git a/app/views/teams/clinics.html.erb b/app/views/teams/clinics.html.erb new file mode 100644 index 0000000000..f5ca50c28f --- /dev/null +++ b/app/views/teams/clinics.html.erb @@ -0,0 +1,38 @@ +<%= h1 t("teams.show.title") do %> + <%= @team.name %> + <%= t("teams.show.title") %> +<% end %> + +

+
+ <%= render AppTeamNavigationComponent.new(team: @team) %> +
+
+ <%= render AppCardComponent.new do |card| %> + <% card.with_heading(level: 2) { "Clinics" } %> + + <%= govuk_table do |table| %> + <% table.with_head do |head| %> + <% head.with_row do |row| %> + <% row.with_cell { "Name" } %> + <% end %> + <% end %> + <% table.with_body do |body| %> + <% @clinics.each do |clinic| %> + <% body.with_row do |row| %> + <% row.with_cell { + [ + clinic.name, + tag.span(format_address_single_line(clinic), class: "nhsuk-u-secondary-text-colour"), + ] + .compact + .join(tag.br) + .html_safe + } %> + <% end %> + <% end %> + <% end %> + <% end %> + <% end %> +
+
diff --git a/app/views/teams/contact_details.html.erb b/app/views/teams/contact_details.html.erb new file mode 100644 index 0000000000..0453de14d5 --- /dev/null +++ b/app/views/teams/contact_details.html.erb @@ -0,0 +1,27 @@ +<%= h1 t("teams.show.title") do %> + <%= @team.name %> + <%= t("teams.show.title") %> +<% end %> + +
+
+ <%= render AppTeamNavigationComponent.new(team: @team) %> +
+
+ <%= render AppCardComponent.new do |card| %> + <% card.with_heading(level: 2) { "Contact details" } %> + + <%= govuk_summary_list do |summary_list| + summary_list.with_row do |row| + row.with_key { "Email address" } + row.with_value { @team.email } + end + + summary_list.with_row do |row| + row.with_key { "Phone number" } + row.with_value { format_phone_with_instructions(@team) } + end + end %> + <% end %> +
+
\ No newline at end of file diff --git a/app/views/teams/schools.html.erb b/app/views/teams/schools.html.erb new file mode 100644 index 0000000000..826d7788a9 --- /dev/null +++ b/app/views/teams/schools.html.erb @@ -0,0 +1,40 @@ +<%= h1 t("teams.show.title") do %> + <%= @team.name %> + <%= t("teams.show.title") %> +<% end %> + +
+
+ <%= render AppTeamNavigationComponent.new(team: @team) %> +
+
+ <%= render AppCardComponent.new do |card| %> + <% card.with_heading(level: 2) { "Schools" } %> + + <%= govuk_table do |table| %> + <% table.with_head do |head| %> + <% head.with_row do |row| %> + <% row.with_cell { "Name" } %> + <% row.with_cell { "URN" } %> + <% end %> + <% end %> + <% table.with_body do |body| %> + <% @schools.each do |school| %> + <% body.with_row do |row| %> + <% row.with_cell { + [ + school.name, + tag.span(format_address_single_line(school), class: "nhsuk-u-secondary-text-colour"), + ] + .compact + .join(tag.br) + .html_safe + } %> + <% row.with_cell { tag.span(school.urn_and_site, class: "app-u-code") } %> + <% end %> + <% end %> + <% end %> + <% end %> + <% end %> +
+
\ No newline at end of file diff --git a/app/views/teams/sessions.html.erb b/app/views/teams/sessions.html.erb new file mode 100644 index 0000000000..ab9927951d --- /dev/null +++ b/app/views/teams/sessions.html.erb @@ -0,0 +1,32 @@ +<%= h1 t("teams.show.title") do %> + <%= @team.name %> + <%= t("teams.show.title") %> +<% end %> + +
+
+ <%= render AppTeamNavigationComponent.new(team: @team) %> +
+
+ <%= render AppCardComponent.new do |card| %> + <% card.with_heading(level: 2) { "Session defaults" } %> + + <%= govuk_summary_list do |summary_list| + summary_list.with_row do |row| + row.with_key { "Consent requests" } + row.with_value { "Send #{pluralize(@team.weeks_before_consent_requests, "week")} before first session" } + end + + summary_list.with_row do |row| + row.with_key { "Consent reminders" } + row.with_value { "Send #{pluralize(@team.weeks_before_consent_reminders, "week")} before each session" } + end + + summary_list.with_row do |row| + row.with_key { "Invitations" } + row.with_value { "Send #{pluralize(@team.weeks_before_invitations, "week")} before first session" } + end + end %> + <% end %> +
+
\ No newline at end of file diff --git a/app/views/teams/show.html.erb b/app/views/teams/show.html.erb deleted file mode 100644 index 943408a627..0000000000 --- a/app/views/teams/show.html.erb +++ /dev/null @@ -1,41 +0,0 @@ -<%= h1 t(".title") do %> - <%= @team.name %> - <%= t(".title") %> -<% end %> - -<%= render AppCardComponent.new do |card| %> - <% card.with_heading(level: 2) { "Contact details" } %> - - <%= govuk_summary_list do |summary_list| - summary_list.with_row do |row| - row.with_key { "Email address" } - row.with_value { @team.email } - end - - summary_list.with_row do |row| - row.with_key { "Phone number" } - row.with_value { format_phone_with_instructions(@team) } - end - end %> -<% end %> - -<%= render AppCardComponent.new do |card| %> - <% card.with_heading(level: 2) { "Session defaults" } %> - - <%= govuk_summary_list do |summary_list| - summary_list.with_row do |row| - row.with_key { "Consent requests" } - row.with_value { "Send #{pluralize(@team.weeks_before_consent_requests, "week")} before first session" } - end - - summary_list.with_row do |row| - row.with_key { "Consent reminders" } - row.with_value { "Send #{pluralize(@team.weeks_before_consent_reminders, "week")} before each session" } - end - - summary_list.with_row do |row| - row.with_key { "Invitations" } - row.with_value { "Send #{pluralize(@team.weeks_before_invitations, "week")} before first session" } - end - end %> -<% end %> diff --git a/app/views/vaccination_reports/file_format.html.erb b/app/views/vaccination_reports/file_format.html.erb index 8cd313a710..72364e7764 100644 --- a/app/views/vaccination_reports/file_format.html.erb +++ b/app/views/vaccination_reports/file_format.html.erb @@ -9,7 +9,7 @@ <%= f.govuk_error_summary %> <%= f.govuk_collection_radio_buttons :file_format, - VaccinationReport::FILE_FORMATS, + @vaccination_report.file_formats, :itself, legend: { text: title, size: "l", tag: "h1" }, caption: { text: @programme.name } %> diff --git a/app/views/vaccination_reports/new.html.erb b/app/views/vaccination_reports/new.html.erb index 64e1435e1f..660c299fc7 100644 --- a/app/views/vaccination_reports/new.html.erb +++ b/app/views/vaccination_reports/new.html.erb @@ -40,7 +40,7 @@
<%= f.govuk_collection_radio_buttons :file_format, - VaccinationReport::FILE_FORMATS, + @vaccination_report.file_formats, :itself, legend: { text: "Select file format", size: "m" } %>
diff --git a/config/locales/en.yml b/config/locales/en.yml index 2895ed65af..00c4966e56 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -13,10 +13,10 @@ en: missing_year: Enter a year taken: This batch already exists name: - blank: Enter a batch - invalid: Enter a batch with only letters and numbers - too_short: Enter a batch that is more than %{count} characters long - too_long: Enter a batch that is less than %{count} characters long + blank: Enter a batch number + invalid: Enter a batch number with only letters and numbers + too_short: Enter a batch number that is more than %{count} characters long + too_long: Enter a batch number that is less than %{count} characters long bulk_remove_parents_form: attributes: remove_option: @@ -114,6 +114,16 @@ en: attributes: batch_id: blank: Choose a batch + batch_name: + blank: Enter a batch number + invalid: Enter a batch number with only letters and numbers + too_short: Enter a batch number that is more than %{count} characters long + too_long: Enter a batch number that is less than %{count} characters long + batch_expiry: + blank: Enter an expiry date + missing_day: Enter a day + missing_month: Enter a month + missing_year: Enter a year delivery_method: blank: Choose a method of delivery inclusion: Choose a method of delivery diff --git a/config/locales/status.en.yml b/config/locales/status.en.yml index 87952f66f0..e8ea2d877a 100644 --- a/config/locales/status.en.yml +++ b/config/locales/status.en.yml @@ -6,7 +6,6 @@ en: programme: Programme status registration: Registration status triage: Triage status - vaccination: Programme status consent: label: conflicts: Conflicting consent @@ -140,14 +139,3 @@ en: safe_to_vaccinate_nasal: aqua-green safe_to_vaccinate_injection_without_gelatine: aqua-green safe_to_vaccinate_injection_without_gelatine_flu: aqua-green - vaccination: - label: - due: Due vaccination - eligible: Eligible - not_eligible: Not eligible - vaccinated: Vaccinated - colour: - due: aqua-green - eligible: white - not_eligible: grey - vaccinated: green diff --git a/config/routes.rb b/config/routes.rb index eb79d97e3f..1fea527702 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -319,7 +319,14 @@ end end - resource :team, only: %i[show] + resource :team, only: [] do + member do + get :contact_details + get :schools + get :sessions + get :clinics + end + end resources :vaccination_records, path: "vaccination-records", diff --git a/config/settings.yml b/config/settings.yml index f8fbc27d94..8caa4b14d3 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -20,6 +20,7 @@ cis2: private_key: | <%= Rails.application.credentials.cis2&.private_key&.gsub(/^/, " ") %> secret: <%= Rails.application.credentials.cis2&.secret %> + support_organisation: Y90128 govuk_notify: enabled: true diff --git a/config/settings/staging.yml b/config/settings/staging.yml index d9573fea12..f814d7c95b 100644 --- a/config/settings/staging.yml +++ b/config/settings/staging.yml @@ -11,6 +11,7 @@ nhs_api: # Devise. cis2: issuer: "https://am.nhsint.auth-ptl.cis2.spineservices.nhs.uk:443/openam/oauth2/realms/root/realms/NHSIdentity/realms/Healthcare" + support_organisation: RX4 pds: raise_unknown_gp_practice: false diff --git a/db/migrate/20250718090719_add_academic_year_to_patient_statuses.rb b/db/migrate/20250718090719_add_academic_year_to_patient_statuses.rb index 928f24738a..97c971426b 100644 --- a/db/migrate/20250718090719_add_academic_year_to_patient_statuses.rb +++ b/db/migrate/20250718090719_add_academic_year_to_patient_statuses.rb @@ -14,7 +14,6 @@ def up Patient::ConsentStatus.update_all(academic_year:) Patient::TriageStatus.update_all(academic_year:) - Patient::VaccinationStatus.update_all(academic_year:) TABLES.each do |table| change_table table, bulk: true do |t| diff --git a/db/migrate/20260117090429_add_care_plus_staff_code_and_care_plus_staff_type_to_team.rb b/db/migrate/20260117090429_add_care_plus_staff_code_and_care_plus_staff_type_to_team.rb new file mode 100644 index 0000000000..112a29fed2 --- /dev/null +++ b/db/migrate/20260117090429_add_care_plus_staff_code_and_care_plus_staff_type_to_team.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddCarePlusStaffCodeAndCarePlusStaffTypeToTeam < ActiveRecord::Migration[ + 8.1 +] + def change + change_table :teams do |t| + t.string :careplus_staff_code, :careplus_staff_type + end + end +end diff --git a/db/migrate/20260119000000_remove_not_null_from_careplus_venue_code_on_teams.rb b/db/migrate/20260119000000_remove_not_null_from_careplus_venue_code_on_teams.rb new file mode 100644 index 0000000000..a7e3559d7a --- /dev/null +++ b/db/migrate/20260119000000_remove_not_null_from_careplus_venue_code_on_teams.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class RemoveNotNullFromCareplusVenueCodeOnTeams < ActiveRecord::Migration[8.1] + def change + change_column_null :teams, :careplus_venue_code, true + end +end diff --git a/db/migrate/20260119042429_update_reporting_api_totals_to_version_3.rb b/db/migrate/20260119042429_update_reporting_api_totals_to_version_3.rb new file mode 100644 index 0000000000..44a3766b9a --- /dev/null +++ b/db/migrate/20260119042429_update_reporting_api_totals_to_version_3.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class UpdateReportingAPITotalsToVersion3 < ActiveRecord::Migration[8.1] + def change + update_view :reporting_api_totals, + version: 3, + revert_to_version: 2, + materialized: true + end +end diff --git a/db/migrate/20260122093544_update_reporting_api_totals_to_version_4.rb b/db/migrate/20260122093544_update_reporting_api_totals_to_version_4.rb new file mode 100644 index 0000000000..3dffb9cf11 --- /dev/null +++ b/db/migrate/20260122093544_update_reporting_api_totals_to_version_4.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class UpdateReportingAPITotalsToVersion4 < ActiveRecord::Migration[8.1] + def change + update_view :reporting_api_totals, + version: 4, + revert_to_version: 3, + materialized: true + end +end diff --git a/db/schema.rb b/db/schema.rb index faf1b005ab..747e59b20a 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.1].define(version: 2026_01_19_161311) do +ActiveRecord::Schema[8.1].define(version: 2026_01_22_093544) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_trgm" @@ -863,7 +863,9 @@ end create_table "teams", force: :cascade do |t| - t.string "careplus_venue_code", null: false + t.string "careplus_staff_code" + t.string "careplus_staff_type" + t.string "careplus_venue_code" t.datetime "created_at", null: false t.integer "days_before_consent_reminders", default: 7, null: false t.integer "days_before_consent_requests", default: 21, null: false @@ -1378,6 +1380,16 @@ ((pps.academic_year - pat.birth_academic_year) - 5) AS patient_year_group, COALESCE(la.mhclg_code, ''::character varying) AS patient_local_authority_code, COALESCE(la.mhclg_code, ''::character varying) AS patient_school_local_authority_code, + CASE + WHEN (school.urn IS NOT NULL) THEN school.urn + WHEN (pat.home_educated = true) THEN '999999'::character varying + ELSE '888888'::character varying + END AS patient_school_urn, + CASE + WHEN (school.name IS NOT NULL) THEN school.name + WHEN (pat.home_educated = true) THEN 'Home-schooled'::text + ELSE 'Unknown school'::text + END AS patient_school_name, (ar.patient_id IS NOT NULL) AS is_archived, (EXISTS ( SELECT 1 FROM consents con diff --git a/db/seeds.rb b/db/seeds.rb index 93662c0f20..72039a7313 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -27,6 +27,7 @@ def create_team(ods_code:, workgroup: nil, type: :poc_only) FactoryBot.create( :team, :with_generic_clinic, + :with_careplus_enabled, ods_code:, programmes: Programme.all, workgroup:, @@ -208,7 +209,7 @@ def setup_clinic(team) PatientLocation.import( new_patient_location_records, on_duplicate_key_ignore: :all - ).ids + ) end def create_patients(team) diff --git a/db/views/reporting_api_totals_v03.sql b/db/views/reporting_api_totals_v03.sql new file mode 100644 index 0000000000..bbc2353135 --- /dev/null +++ b/db/views/reporting_api_totals_v03.sql @@ -0,0 +1,91 @@ +SELECT + -- Composite key for unique index (required for concurrent refresh) + pps.patient_id || '-' || + pps.programme_type || '-' || + tl.team_id || '-' || + pl.location_id || '-' || + pps.academic_year AS id, + + -- Identifiers (for counting and grouping) + pps.patient_id, -- COUNT(DISTINCT patient_id) for totals + pps.academic_year, -- Filter: ?academic_year=2024 + pps.programme_type, -- Filter: ?programme=hpv + pps.status, -- Scope: .vaccinated (status IN 60,61) + tl.team_id, -- Filter: by user's team_ids + pl.location_id AS session_location_id, -- Year group eligibility subquery + + -- Patient demographics (used for filtering and CSV grouping) + CASE pat.gender_code + WHEN 0 THEN 'not known' + WHEN 1 THEN 'male' + WHEN 2 THEN 'female' + WHEN 9 THEN 'not specified' + ELSE NULL + END AS patient_gender, -- Filter: ?gender=female + + pps.academic_year + - pat.birth_academic_year - 5 AS patient_year_group, -- Filter: ?year_group=8,9 + COALESCE(la.mhclg_code, '') AS patient_local_authority_code, -- Filter: ?local_authority=E09000001 + COALESCE(la.mhclg_code, '') AS patient_school_local_authority_code, -- Filter: ?school_local_authority=E09000001 + + -- School info (for CSV grouping by school) + CASE + WHEN school.urn IS NOT NULL THEN school.urn + WHEN pat.home_educated = true THEN '999999' + ELSE '888888' + END AS patient_school_urn, + CASE + WHEN school.name IS NOT NULL THEN school.name + WHEN pat.home_educated = true THEN 'Home educated' + ELSE 'Unknown' + END AS patient_school_name, + + -- Status flags + ar.patient_id IS NOT NULL AS is_archived, -- Scope: .not_archived + + -- Parent declared "already vaccinated" (counts toward vaccinated total) + EXISTS ( + SELECT 1 FROM consents con + WHERE con.patient_id = pps.patient_id + AND con.programme_type = pps.programme_type + AND con.academic_year = pps.academic_year + AND con.invalidated_at IS NULL + AND con.withdrawn_at IS NULL + AND con.response = 1 -- refused + AND con.reason_for_refusal = 1 -- already_vaccinated + ) AS has_already_vaccinated_consent + +-- Source: pre-computed patient status per programme/year +FROM patient_programme_statuses pps + +-- Patient record (for demographics and exclusion checks) +JOIN patients pat + ON pat.id = pps.patient_id + +-- Where the patient is enrolled this year (links to team via location) +JOIN patient_locations pl + ON pl.patient_id = pps.patient_id + AND pl.academic_year = pps.academic_year + +-- Which team owns this location (for team_id filtering) +JOIN team_locations tl + ON tl.location_id = pl.location_id + AND tl.academic_year = pps.academic_year + +-- Check if patient is archived by this team (LEFT: most aren't) +LEFT JOIN archive_reasons ar + ON ar.patient_id = pps.patient_id + AND ar.team_id = tl.team_id + +-- Patient's school (LEFT: home-educated patients have no school) +LEFT JOIN locations school + ON school.id = pat.school_id + +-- School's local authority (LEFT: school may not have LA set) +LEFT JOIN local_authorities la + ON la.gias_code = school.gias_local_authority_code + +-- Exclude patients who shouldn't appear in any reports +WHERE pat.invalidated_at IS NULL -- Merged/duplicate record + AND pat.restricted_at IS NULL -- S31 restricted access + AND pat.date_of_death IS NULL -- Deceased diff --git a/db/views/reporting_api_totals_v04.sql b/db/views/reporting_api_totals_v04.sql new file mode 100644 index 0000000000..40f9c03afc --- /dev/null +++ b/db/views/reporting_api_totals_v04.sql @@ -0,0 +1,91 @@ +SELECT + -- Composite key for unique index (required for concurrent refresh) + pps.patient_id || '-' || + pps.programme_type || '-' || + tl.team_id || '-' || + pl.location_id || '-' || + pps.academic_year AS id, + + -- Identifiers (for counting and grouping) + pps.patient_id, -- COUNT(DISTINCT patient_id) for totals + pps.academic_year, -- Filter: ?academic_year=2024 + pps.programme_type, -- Filter: ?programme=hpv + pps.status, -- Scope: .vaccinated (status IN 60,61) + tl.team_id, -- Filter: by user's team_ids + pl.location_id AS session_location_id, -- Year group eligibility subquery + + -- Patient demographics (used for filtering and CSV grouping) + CASE pat.gender_code + WHEN 0 THEN 'not known' + WHEN 1 THEN 'male' + WHEN 2 THEN 'female' + WHEN 9 THEN 'not specified' + ELSE NULL + END AS patient_gender, -- Filter: ?gender=female + + pps.academic_year + - pat.birth_academic_year - 5 AS patient_year_group, -- Filter: ?year_group=8,9 + COALESCE(la.mhclg_code, '') AS patient_local_authority_code, -- Filter: ?local_authority=E09000001 + COALESCE(la.mhclg_code, '') AS patient_school_local_authority_code, -- Filter: ?school_local_authority=E09000001 + + -- School info (for CSV grouping by school) + CASE + WHEN school.urn IS NOT NULL THEN school.urn + WHEN pat.home_educated = true THEN '999999' + ELSE '888888' + END AS patient_school_urn, + CASE + WHEN school.name IS NOT NULL THEN school.name + WHEN pat.home_educated = true THEN 'Home-schooled' + ELSE 'Unknown school' + END AS patient_school_name, + + -- Status flags + ar.patient_id IS NOT NULL AS is_archived, -- Scope: .not_archived + + -- Parent declared "already vaccinated" (counts toward vaccinated total) + EXISTS ( + SELECT 1 FROM consents con + WHERE con.patient_id = pps.patient_id + AND con.programme_type = pps.programme_type + AND con.academic_year = pps.academic_year + AND con.invalidated_at IS NULL + AND con.withdrawn_at IS NULL + AND con.response = 1 -- refused + AND con.reason_for_refusal = 1 -- already_vaccinated + ) AS has_already_vaccinated_consent + +-- Source: pre-computed patient status per programme/year +FROM patient_programme_statuses pps + +-- Patient record (for demographics and exclusion checks) +JOIN patients pat + ON pat.id = pps.patient_id + +-- Where the patient is enrolled this year (links to team via location) +JOIN patient_locations pl + ON pl.patient_id = pps.patient_id + AND pl.academic_year = pps.academic_year + +-- Which team owns this location (for team_id filtering) +JOIN team_locations tl + ON tl.location_id = pl.location_id + AND tl.academic_year = pps.academic_year + +-- Check if patient is archived by this team (LEFT: most aren't) +LEFT JOIN archive_reasons ar + ON ar.patient_id = pps.patient_id + AND ar.team_id = tl.team_id + +-- Patient's school (LEFT: home-educated patients have no school) +LEFT JOIN locations school + ON school.id = pat.school_id + +-- School's local authority (LEFT: school may not have LA set) +LEFT JOIN local_authorities la + ON la.gias_code = school.gias_local_authority_code + +-- Exclude patients who shouldn't appear in any reports +WHERE pat.invalidated_at IS NULL -- Merged/duplicate record + AND pat.restricted_at IS NULL -- S31 restricted access + AND pat.date_of_death IS NULL -- Deceased diff --git a/docs/managing-teams.md b/docs/managing-teams.md index 27c825a3eb..8129426e49 100644 --- a/docs/managing-teams.md +++ b/docs/managing-teams.md @@ -88,22 +88,22 @@ If any validation errors are detected in the file they will be output and nothin Once a team has been onboarding, the YAML configuration file can be deleted as it won’t be used again. Instead, a number of command line tools are provided for managing the team. -### Adding new schools to an organisation +### Adding new schools to a team -The command `schools add-to-organisation` is provided to add new schools to an existing organisation. +The command `schools add-to-team` is provided to add new schools to an existing team. ```sh -$ bin/mavis schools add-to-organisation ODS_CODE SUBTEAM URNS +$ bin/mavis schools add-to-team TEAM_WORKGROUP SUBTEAM_NAME URNS ``` -- `ODS_CODE` refers to the ODS code of the organisation -- `SUBTEAM` refers to the name of the subteam in the organisation +- `TEAM_WORKGROUP` refers to the workgroup of the team +- `SUBTEAM_NAME` refers to the name of the subteam in the team - `URNS` are the URNs of the schools to add Optionally, it's also possible to customise which programmes are administered at a particular school: ```sh -$ bin/mavis schools add-to-organisation ODS_CODE SUBTEAM URNS --programmes VALUE1,VALUE2,... +$ bin/mavis schools add-to-team TEAM_WORKGROUP SUBTEAM_NAME URNS --programmes VALUE1,VALUE2,... ``` ### Changing administered year groups of a school @@ -120,3 +120,15 @@ $ bin/mavis schools remove-programme-year-group URN PROGRAMME_TYPE YEAR_GROUPS - `URN` refers to the URN of the school to edit - `PROGRAMME_TYPE` refers to the programme being edited - `YEAR_GROUPS` are the year groups to add or remove + +### Removing a school from a team + +The command `schools add-to-team` is provided to add new schools to an existing team. + +```sh +$ bin/mavis schools add-to-team TEAM_WORKGROUP SUBTEAM_NAME URNS +``` + +- `TEAM_WORKGROUP` refers to the workgroup of the team +- `SUBTEAM_NAME` refers to the name of the subteam in the team +- `URNS` are the URNs of the schools to add diff --git a/lib/tasks/rdoc.rake b/lib/tasks/rdoc.rake new file mode 100644 index 0000000000..fc2c14ea8c --- /dev/null +++ b/lib/tasks/rdoc.rake @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rdoc/task" + +namespace :rdoc do + RDoc::Task.new(:generate) do |rdoc| + rdoc.title = "Mavis Documentation" + rdoc.main = "README.md" + rdoc.rdoc_dir = "docs/rdoc" + rdoc.rdoc_files.include( + "README.md", + "docs/*.md", + "lib/**/*.rb", + "app/**/*.rb" + ) + end +end diff --git a/lib/tasks/vaccines.rake b/lib/tasks/vaccines.rake index 59fdd0e80e..ad761a973f 100644 --- a/lib/tasks/vaccines.rake +++ b/lib/tasks/vaccines.rake @@ -48,7 +48,7 @@ namespace :vaccines do when "td_ipv" create_td_ipv_health_questions(vaccine) else - raise UnsupportedProgramme, Programme.find(programme_type) + raise UnsupportedProgrammeType, programme_type end end end @@ -139,7 +139,7 @@ def side_effects_for(programme_type, method) unwell ] else - raise UnsupportedProgramme, Programme.find(programme_type) + raise UnsupportedProgrammeType, programme_type end end diff --git a/package.json b/package.json index 748949df83..9390eb6852 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "jest-environment-jsdom": "^30.2.0", "jest-fetch-mock": "^3.0.3", "officecrypto-tool": "^0.0.19", - "prettier": "^3.8.0", + "prettier": "^3.8.1", "stylelint": "^16.26.1", "stylelint-config-gds": "^2.0.0", "stylelint-order": "^7.0.1" diff --git a/spec/components/app_activity_log_component_spec.rb b/spec/components/app_activity_log_component_spec.rb index 7f41e421f0..6e0f56c3cb 100644 --- a/spec/components/app_activity_log_component_spec.rb +++ b/spec/components/app_activity_log_component_spec.rb @@ -698,10 +698,7 @@ session: session_last_year, performed_at: 1.year.ago ) - patient.vaccination_status( - programme: flu_programme, - academic_year: 2024 - ).assign_status + patient.programme_status(flu_programme, academic_year: 2024).assign end include_examples "card", diff --git a/spec/components/app_patient_session_consent_component_spec.rb b/spec/components/app_patient_session_consent_component_spec.rb index 72370d570d..015523dcbd 100644 --- a/spec/components/app_patient_session_consent_component_spec.rb +++ b/spec/components/app_patient_session_consent_component_spec.rb @@ -27,7 +27,7 @@ context "when vaccinated" do before do - create(:patient_vaccination_status, :vaccinated, patient:, programme:) + create(:patient_programme_status, :vaccinated_fully, patient:, programme:) end it { should_not have_css("p", text: "No requests have been sent.") } @@ -41,8 +41,6 @@ create(:consent, :refused, patient: patient.reload, parent:, programme:) end - before { create(:patient_consent_status, :refused, patient:, programme:) } - it { should have_css(".app-card__heading--red", text: "Consent refused") } it { should have_content(consent.parent.full_name) } it { should have_content(consent.parent_relationship.label) } diff --git a/spec/components/app_session_overview_component_spec.rb b/spec/components/app_session_overview_component_spec.rb index ab9d49f756..fee0a52552 100644 --- a/spec/components/app_session_overview_component_spec.rb +++ b/spec/components/app_session_overview_component_spec.rb @@ -68,7 +68,8 @@ :patient_programme_status, :vaccinated_fully, patient:, - programme: flu_programme + programme: flu_programme, + location: session.location ) end @@ -140,13 +141,6 @@ programme: hpv_programme, academic_year: AcademicYear.current - 1 ) - create( - :patient_vaccination_status, - :vaccinated, - patient:, - programme: hpv_programme, - academic_year: AcademicYear.current - 1 - ) end include_examples "displays correct children due vaccination", "HPV", 0 @@ -229,19 +223,22 @@ :patient_programme_status, :has_refusal_consent_refused, patient: patients.first, - programme: hpv_programme + programme: hpv_programme, + location: session.location ) create( :patient_programme_status, :cannot_vaccinate_unwell, programme: hpv_programme, - patient: patients.second + patient: patients.second, + location: session.location ) create( :patient_programme_status, :vaccinated_fully, programme: hpv_programme, - patient: patients.third + patient: patients.third, + location: session.location ) end diff --git a/spec/components/app_sub_navigation_component_spec.rb b/spec/components/app_sub_navigation_component_spec.rb new file mode 100644 index 0000000000..715b8bcee7 --- /dev/null +++ b/spec/components/app_sub_navigation_component_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +describe AppSubNavigationComponent do + subject(:rendered) { render_inline(component) } + + let(:component) do + described_class.new.tap do |nav| + nav.with_item(selected: true, href: "https://example.com") { "Example 1" } + nav.with_item( + selected: false, + text: "Example 2", + href: "https://example.com" + ) + end + end + + it { should have_css("nav.app-sub-navigation") } + + it { should have_css("ul.app-sub-navigation__section") } + it { should have_css("li.app-sub-navigation__section-item") } + + it { should have_css(".app-sub-navigation__section-item--current").once } + + it { should have_link("Example 1", href: "https://example.com") } + it { should have_link("Example 2", href: "https://example.com") } +end diff --git a/spec/components/app_vaccination_record_summary_component_spec.rb b/spec/components/app_vaccination_record_summary_component_spec.rb index 5128887a00..3694731fd3 100644 --- a/spec/components/app_vaccination_record_summary_component_spec.rb +++ b/spec/components/app_vaccination_record_summary_component_spec.rb @@ -479,6 +479,22 @@ it_behaves_like "should not have a `Synced with NHS England?` row" end + + context "when the vaccination record was sourced from a national reporting upload and the flag is off" do + let(:source) { "bulk_upload" } + let(:session) { nil } + + it_behaves_like "should not have a `Synced with NHS England?` row" + end + + context "when the vaccination record was sourced from a national reporting upload and the flag is on" do + before { Flipper.enable(:sync_national_reporting_to_imms_api) } + + let(:source) { "bulk_upload" } + let(:session) { nil } + + it_behaves_like "should have a `Synced with NHS England?` row" + end end describe "with pending changes" do diff --git a/spec/controllers/api/reporting/totals_controller_spec.rb b/spec/controllers/api/reporting/totals_controller_spec.rb index aee983fbc8..268582c231 100644 --- a/spec/controllers/api/reporting/totals_controller_spec.rb +++ b/spec/controllers/api/reporting/totals_controller_spec.rb @@ -195,6 +195,63 @@ expect(response).to have_http_status(:ok) expect(parsed_response["cohort"]).to eq(2) end + + it "returns grouped JSON data by school" do + team = Team.last + programme = Programme.hpv + team.programmes << programme + session = create(:session, team:, programmes: [programme]) + + school_one = create(:school, name: "School One", urn: "111111") + school_two = create(:school, name: "School Two", urn: "222222") + + create(:patient, session:, school: school_one) + patient2 = create(:patient, session:, school: school_two) + create( + :vaccination_record, + patient: patient2, + programme:, + session:, + outcome: "administered", + performed_at: Time.current + ) + + create(:patient, session:, school: nil, home_educated: true) + create(:patient, session:, school: nil, home_educated: false) + + refresh_reporting_views! + + get :index, params: { group: "school", programme: "hpv" } + + expect(response).to have_http_status(:ok) + expect(parsed_response).to be_an(Array) + expect(parsed_response.length).to eq(4) + + school_one_data = parsed_response.find { it["school_urn"] == "111111" } + school_two_data = parsed_response.find { it["school_urn"] == "222222" } + home_educated_data = parsed_response.find { it["school_urn"] == "999999" } + unknown_data = parsed_response.find { it["school_urn"] == "888888" } + + expect(school_one_data["school_name"]).to eq("School One") + expect(school_one_data["cohort"]).to eq(1) + expect(school_one_data["vaccinated"]).to eq(0) + expect(school_one_data["not_vaccinated"]).to eq(1) + + expect(school_two_data["school_name"]).to eq("School Two") + expect(school_two_data["cohort"]).to eq(1) + expect(school_two_data["vaccinated"]).to eq(1) + expect(school_two_data["not_vaccinated"]).to eq(0) + + expect(home_educated_data["school_name"]).to eq("Home-schooled") + expect(home_educated_data["cohort"]).to eq(1) + expect(home_educated_data["vaccinated"]).to eq(0) + expect(home_educated_data["not_vaccinated"]).to eq(1) + + expect(unknown_data["school_name"]).to eq("Unknown school") + expect(unknown_data["cohort"]).to eq(1) + expect(unknown_data["vaccinated"]).to eq(0) + expect(unknown_data["not_vaccinated"]).to eq(1) + end end describe "#index.csv" do @@ -219,6 +276,36 @@ expect(csv.headers).to include("Year Group", "Cohort", "Vaccinated") expect(csv.length).to eq(2) end + + it "returns grouped CSV data by school" do + team = Team.last + programme = Programme.hpv + team.programmes << programme + session = create(:session, team:, programmes: [programme]) + + school_one = create(:school, name: "School One", urn: "111111") + school_two = create(:school, name: "School Two", urn: "222222") + + create(:patient, session:, school: school_one) + create(:patient, session:, school: school_two) + + refresh_reporting_views! + + request.headers["Accept"] = "text/csv" + get :index, params: { group: "school" }, format: :csv + + expect(response).to have_http_status(:ok) + expect(response.content_type).to eq("text/csv") + + csv = CSV.parse(response.body, headers: true) + expect(csv.headers).to include( + "School URN", + "School Name", + "Cohort", + "Vaccinated" + ) + expect(csv.length).to eq(2) + end end describe "Dashboard acceptance criteria" do diff --git a/spec/factories/archive_reasons.rb b/spec/factories/archive_reasons.rb index 6995a14b39..c1633a374c 100644 --- a/spec/factories/archive_reasons.rb +++ b/spec/factories/archive_reasons.rb @@ -38,5 +38,12 @@ type { "other" } other_details { Faker::Lorem.sentence } end + + after(:create) do |archive_reason| + PatientTeamUpdater.call( + patient_scope: Patient.where(id: archive_reason.patient_id), + team_scope: Team.where(id: archive_reason.team_id) + ) + end end end diff --git a/spec/factories/patient_locations.rb b/spec/factories/patient_locations.rb index 8d62cfd41a..e7dc34c908 100644 --- a/spec/factories/patient_locations.rb +++ b/spec/factories/patient_locations.rb @@ -30,5 +30,12 @@ patient location { session.location } academic_year { session.academic_year } + + after(:create) do |patient_location| + PatientTeamUpdater.call( + patient_scope: Patient.where(id: patient_location.patient_id), + team_scope: patient_location.location.teams + ) + end end end diff --git a/spec/factories/patient_vaccination_statuses.rb b/spec/factories/patient_vaccination_statuses.rb deleted file mode 100644 index 0cb1b7a271..0000000000 --- a/spec/factories/patient_vaccination_statuses.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -# == Schema Information -# -# Table name: patient_vaccination_statuses -# -# id :bigint not null, primary key -# academic_year :integer not null -# dose_sequence :integer -# latest_date :date -# latest_session_status :integer -# programme_type :enum not null -# status :integer default("not_eligible"), not null -# latest_location_id :bigint -# patient_id :bigint not null -# -# Indexes -# -# idx_on_academic_year_patient_id_9c400fc863 (academic_year,patient_id) -# idx_on_patient_id_programme_type_academic_year_962639d2ac (patient_id,programme_type,academic_year) UNIQUE -# index_patient_vaccination_statuses_on_latest_location_id (latest_location_id) -# index_patient_vaccination_statuses_on_status (status) -# -# Foreign Keys -# -# fk_rails_... (latest_location_id => locations.id) -# fk_rails_... (patient_id => patients.id) ON DELETE => cascade -# -FactoryBot.define do - factory :patient_vaccination_status, class: "Patient::VaccinationStatus" do - patient - programme { Programme.sample } - academic_year { AcademicYear.current } - - traits_for_enum :status - - trait :vaccinated do - status { "vaccinated" } - latest_date { Date.current } - end - end -end diff --git a/spec/factories/patients.rb b/spec/factories/patients.rb index 74d7dca8ee..ac2575a4c7 100644 --- a/spec/factories/patients.rb +++ b/spec/factories/patients.rb @@ -126,7 +126,7 @@ after(:create) do |patient, evaluator| if (location = evaluator.location) && (academic_year = evaluator.academic_year) - PatientLocation.find_or_create_by!(patient:, location:, academic_year:) + create(:patient_location, patient:, location:, academic_year:) end end @@ -214,19 +214,6 @@ end end - trait :eligible_for_vaccination do - vaccination_statuses do - programmes.map do |programme| - association( - :patient_vaccination_status, - :eligible, - patient: instance, - programme: - ) - end - end - end - trait :due_for_vaccination do programme_statuses do programmes.map do |programme| @@ -238,16 +225,6 @@ ) end end - vaccination_statuses do - programmes.map do |programme| - association( - :patient_vaccination_status, - :due, - patient: instance, - programme: - ) - end - end end trait :consent_no_response do @@ -451,7 +428,6 @@ trait :consent_given_triage_needed do triage_required - eligible_for_vaccination consents do programmes.map do |programme| @@ -489,8 +465,6 @@ end trait :consent_given_injection_only_triage_needed do - eligible_for_vaccination - consents do programmes.map do |programme| association( @@ -538,8 +512,6 @@ end trait :consent_given_nasal_only_triage_needed do - eligible_for_vaccination - consents do programmes.map do |programme| association( @@ -831,7 +803,7 @@ programmes.map do |programme| association( :consent, - :given_nasal, + :given_nasal_or_injection, :from_mum, :health_question_notes, patient: instance, @@ -841,6 +813,18 @@ end end + triages do + programmes.map do |programme| + association( + :triage, + :safe_to_vaccinate_without_gelatine, + patient: instance, + team:, + programme: + ) + end + end + consent_statuses do programmes.map do |programme| association( @@ -1162,16 +1146,6 @@ ) end end - vaccination_statuses do - programmes.map do |programme| - association( - :patient_vaccination_status, - :vaccinated, - patient: instance, - programme: - ) - end - end end trait :bulk_uploaded do diff --git a/spec/factories/school_moves.rb b/spec/factories/school_moves.rb index 996be31c3f..533556ef37 100644 --- a/spec/factories/school_moves.rb +++ b/spec/factories/school_moves.rb @@ -51,5 +51,19 @@ team school { nil } end + + after(:create) do |school_move| + team_scope = + if (id = school_move.team_id) + Team.where(id:) + else + school_move.school.teams + end + + PatientTeamUpdater.call( + patient_scope: Patient.where(id: school_move.patient_id), + team_scope: + ) + end end end diff --git a/spec/factories/team_locations.rb b/spec/factories/team_locations.rb index e5e9f9dfdd..0538cb270b 100644 --- a/spec/factories/team_locations.rb +++ b/spec/factories/team_locations.rb @@ -31,5 +31,9 @@ team location academic_year { AcademicYear.current } + + after(:create) do |team_location| + PatientTeamUpdater.call(team_scope: Team.where(id: team_location.team_id)) + end end end diff --git a/spec/factories/teams.rb b/spec/factories/teams.rb index 8e4bd03cda..52421cc77e 100644 --- a/spec/factories/teams.rb +++ b/spec/factories/teams.rb @@ -5,7 +5,9 @@ # Table name: teams # # id :bigint not null, primary key -# careplus_venue_code :string not null +# careplus_staff_code :string +# careplus_staff_type :string +# careplus_venue_code :string # days_before_consent_reminders :integer default(7), not null # days_before_consent_requests :integer default(21), not null # days_before_invitations :integer default(21), not null @@ -49,7 +51,7 @@ name { "SAIS Team #{identifier}" } email { "sais-team-#{identifier}@example.com" } phone { "01234 567890" } - careplus_venue_code { identifier.to_s } + privacy_notice_url { "https://example.com/privacy-notice" } privacy_policy_url { "https://example.com/privacy-policy" } type { :poc_only } @@ -71,5 +73,11 @@ GenericClinicFactory.call(team:, academic_year: AcademicYear.pending) end end + + trait :with_careplus_enabled do + careplus_staff_code { "LW5PM" } + careplus_staff_type { "IN" } + careplus_venue_code { identifier.to_s } + end end end diff --git a/spec/factories/vaccination_records.rb b/spec/factories/vaccination_records.rb index 690b08cc77..3217ebedf2 100644 --- a/spec/factories/vaccination_records.rb +++ b/spec/factories/vaccination_records.rb @@ -122,6 +122,10 @@ notify_parents { true } after(:create) do |vaccination_record| + PatientTeamUpdater.call( + patient_scope: Patient.where(id: vaccination_record.patient_id) + ) + ImportantNoticeGeneratorJob.perform_now([vaccination_record.patient_id]) end diff --git a/spec/features/cli_clinics_add_to_team_spec.rb b/spec/features/cli_clinics_add_to_team_spec.rb index b8516e9d4d..833822fa5c 100644 --- a/spec/features/cli_clinics_add_to_team_spec.rb +++ b/spec/features/cli_clinics_add_to_team_spec.rb @@ -10,6 +10,15 @@ end end + context "when the subteam doesn't exist" do + it "displays an error message" do + given_the_team_exists + + when_i_run_the_command_expecting_an_error + then_a_subteam_not_found_error_message_is_displayed + end + end + context "when the clinic doesn't exist" do it "displays an error message" do given_the_team_exists @@ -31,6 +40,20 @@ end end + context "when the school belongs to another subteam" do + it "displays a warning message" do + given_the_team_exists + and_the_subteam_exists + and_the_clinic_exists + and_the_clinic_belongs_to_another_subteam + + when_i_run_the_command + then_a_clinic_belongs_to_another_team_warning_message_is_displayed + then_the_clinic_is_added_to_the_team + and_the_clinic_remains_in_the_other_team_too + end + end + private def command @@ -51,8 +74,18 @@ def and_the_clinic_exists @clinic = create(:community_clinic, name: "Clinic", ods_code: "123456") end + def and_the_clinic_belongs_to_another_subteam + @other_team = create(:team, name: "Other Team") + @other_subteam = create(:subteam, name: "Other Subteam", team: @other_team) + @clinic.attach_to_team!( + @other_team, + academic_year: AcademicYear.pending, + subteam: @other_subteam + ) + end + def when_i_run_the_command - @output = capture_output { command } + @output = capture_error { command } end def when_i_run_the_command_expecting_an_error @@ -60,14 +93,26 @@ def when_i_run_the_command_expecting_an_error end def then_an_team_not_found_error_message_is_displayed - expect(@output).to include("Could not find team.") + expect(@output).to include("Could not find team with workgroup abc.") + end + + def then_a_subteam_not_found_error_message_is_displayed + expect(@output).to include("Could not find subteam with name Team.") end def then_a_clinic_not_found_error_message_is_displayed - expect(@output).to include("Could not find location: 123456") + expect(@output).to include("Could not find clinic with ODS code 123456.") + end + + def then_a_clinic_belongs_to_another_team_warning_message_is_displayed + expect(@output).to include("123456 previously belonged to Other Subteam.") end def then_the_clinic_is_added_to_the_team expect(@team.community_clinics).to include(@clinic) end + + def and_the_clinic_remains_in_the_other_team_too + expect(@other_team.community_clinics).to include(@clinic) + end end diff --git a/spec/features/cli_schools_add_to_team_spec.rb b/spec/features/cli_schools_add_to_team_spec.rb index af98295035..f3330fa3f6 100644 --- a/spec/features/cli_schools_add_to_team_spec.rb +++ b/spec/features/cli_schools_add_to_team_spec.rb @@ -10,6 +10,15 @@ end end + context "when the subteam doesn't exist" do + it "displays an error message" do + given_the_team_exists + + when_i_run_the_command_expecting_an_error + then_a_subteam_not_found_error_message_is_displayed + end + end + context "when the school doesn't exist" do it "displays an error message" do given_the_team_exists @@ -31,6 +40,20 @@ end end + context "when the school belongs to another subteam" do + it "displays a warning message" do + given_the_team_exists + and_the_subteam_exists + and_the_school_exists + and_the_school_belongs_to_another_subteam + + when_i_run_the_command + then_a_school_belongs_to_another_team_warning_message_is_displayed + then_the_school_is_added_to_the_team + and_the_school_remains_in_the_other_team_too + end + end + context "when customising the programmes" do it "runs successfully" do given_the_team_exists @@ -69,6 +92,16 @@ def and_the_school_exists @school = create(:school, name: "School", urn: "123456") end + def and_the_school_belongs_to_another_subteam + @other_team = create(:team, name: "Other Team") + @other_subteam = create(:subteam, name: "Other Subteam", team: @other_team) + @school.attach_to_team!( + @other_team, + academic_year: AcademicYear.pending, + subteam: @other_subteam + ) + end + def when_i_run_the_command @output = capture_error { command } end @@ -82,11 +115,19 @@ def when_i_run_the_command_expecting_an_error end def then_an_team_not_found_error_message_is_displayed - expect(@output).to include("Could not find team.") + expect(@output).to include("Could not find team with workgroup abc.") + end + + def then_a_subteam_not_found_error_message_is_displayed + expect(@output).to include("Could not find subteam with name Team.") end def then_a_school_not_found_error_message_is_displayed - expect(@output).to include("Could not find location: 123456") + expect(@output).to include("Could not find school with URN 123456.") + end + + def then_a_school_belongs_to_another_team_warning_message_is_displayed + expect(@output).to include("123456 previously belonged to Other Subteam.") end def then_the_school_is_added_to_the_team @@ -98,4 +139,8 @@ def then_the_school_is_added_to_the_team_with_flu_only expect(@team.schools).to include(@school) expect(@school.programmes).to contain_exactly(@programmes.first) end + + def and_the_school_remains_in_the_other_team_too + expect(@other_team.schools).to include(@school) + end end diff --git a/spec/features/cli_schools_remove_from_team_spec.rb b/spec/features/cli_schools_remove_from_team_spec.rb index b1aef397f9..0e6fd52a28 100644 --- a/spec/features/cli_schools_remove_from_team_spec.rb +++ b/spec/features/cli_schools_remove_from_team_spec.rb @@ -3,27 +3,73 @@ require_relative "../../app/lib/mavis_cli" describe "mavis schools remove-from-team" do - context "with valid arguments" do + context "when the team doesn't exist" do + it "displays an error message" do + when_i_run_the_command_expecting_an_error + then_a_team_not_found_error_message_is_displayed + end + end + + context "when the subteam doesn't exist" do + it "displays an error message" do + given_the_team_exists + + when_i_run_the_command_expecting_an_error + then_a_subteam_not_found_error_message_is_displayed + end + end + + context "when the schools don't exist" do + it "displays an error message" do + given_the_team_exists + and_the_subteam_exists + + when_i_run_the_command_expecting_an_error + then_a_school_not_found_error_message_is_displayed + end + end + + context "when the schools exist" do it "runs successfully" do - given_schools_exist + given_the_team_exists + and_the_subteam_exists + and_the_schools_exist + when_i_run_the_command then_the_schools_are_removed_from_the_team end end + context "when the school isn't in the specified team" do + it "displays an error message" do + given_the_team_exists + and_the_subteam_exists + and_the_schools_exist_but_in_a_different_team + + when_i_run_the_command_expecting_an_error + then_a_team_location_not_found_error_message_is_displayed + end + end + private - def given_schools_exist - team = create(:team, workgroup: "TeamA") - subteam = create(:subteam, team:, name: "SubteamA") + def given_the_team_exists + @team = create(:team, workgroup: "TeamA") + end + + def and_the_subteam_exists + @subteam = create(:subteam, team: @team, name: "SubteamA") + end + + def and_the_schools_exist @school_a = create( :school, urn: "123456", name: "MainSchool", site: nil, - team:, - subteam: + team: @team, + subteam: @subteam ) @school_b = create( @@ -31,13 +77,33 @@ def given_schools_exist urn: "654321", name: "OtherSchool", site: nil, - team:, - subteam: + team: @team, + subteam: @subteam ) expect(@school_a.teams.count).to eq(1) expect(@school_b.teams.count).to eq(1) end + def and_the_schools_exist_but_in_a_different_team + @other_team = create(:team, workgroup: "TeamB") + @school_a = + create( + :school, + urn: "123456", + name: "MainSchool", + site: nil, + team: @other_team + ) + @school_b = + create( + :school, + urn: "654321", + name: "OtherSchool", + site: nil, + team: @other_team + ) + end + def command Dry::CLI.new(MavisCLI).call( arguments: %w[ @@ -57,6 +123,28 @@ def when_i_run_the_command @output = capture_output { command } end + def when_i_run_the_command_expecting_an_error + @output = capture_error { command } + end + + def then_a_team_not_found_error_message_is_displayed + expect(@output).to include("Could not find team with workgroup TeamA") + end + + def then_a_subteam_not_found_error_message_is_displayed + expect(@output).to include("Could not find subteam with name SubteamA") + end + + def then_a_school_not_found_error_message_is_displayed + expect(@output).to include("Could not find school with URN 123456") + expect(@output).to include("Could not find school with URN 654321") + end + + def then_a_team_location_not_found_error_message_is_displayed + expect(@output).to include("Could not find team location for URN 123456") + expect(@output).to include("Could not find team location for URN 654321") + end + def then_the_schools_are_removed_from_the_team expect(@school_a.teams.count).to eq(0) expect(@school_b.teams.count).to eq(0) diff --git a/spec/features/download_vaccination_reports_single_page_spec.rb b/spec/features/download_vaccination_reports_single_page_spec.rb index 5c60ed5dc2..c1f967a3d2 100644 --- a/spec/features/download_vaccination_reports_single_page_spec.rb +++ b/spec/features/download_vaccination_reports_single_page_spec.rb @@ -6,7 +6,9 @@ and_an_administered_vaccination_record_exists when_i_visit_the_single_page_form - and_i_fill_in_the_form + then_i_do_not_see_careplus_as_an_option + + when_i_fill_in_the_form then_i_download_a_csv_file end @@ -18,6 +20,15 @@ then_i_see_validation_errors end + scenario "Download a vaccination report in CarePlus format" do + given_an_hpv_programme_is_underway_and_care_plus_is_enabled + and_an_administered_vaccination_record_exists + + when_i_visit_the_single_page_form + and_i_fill_in_the_form_with_careplus_format + then_i_download_a_careplus_file + end + def given_an_hpv_programme_is_underway @programme = Programme.hpv @team = create(:team, :with_one_nurse, programmes: [@programme]) @@ -37,6 +48,17 @@ def given_an_hpv_programme_is_underway create(:patient_location, patient: @patient, session: @session) end + def given_an_hpv_programme_is_underway_and_care_plus_is_enabled + given_an_hpv_programme_is_underway + @team = + create( + :team, + :with_one_nurse, + :with_careplus_enabled, + programmes: [@programme] + ) + end + def and_an_administered_vaccination_record_exists vaccine = @programme.vaccines.first batch = create(:batch, team: @team, vaccine:) @@ -55,10 +77,22 @@ def when_i_visit_the_single_page_form visit new_vaccination_report_path end - def and_i_fill_in_the_form + def then_i_do_not_see_careplus_as_an_option + expect(page).not_to have_content("CarePlus") + end + + def when_i_fill_in_the_form + fill_in_the_form(file_format: "CSV") + end + + def and_i_fill_in_the_form_with_careplus_format + fill_in_the_form(file_format: "CSV for CarePlus (System C)") + end + + def fill_in_the_form(file_format:) choose "#{AcademicYear.current} to #{AcademicYear.current + 1}" choose "HPV" - choose "CSV" + choose file_format click_on "Download vaccination data" end @@ -76,4 +110,12 @@ def then_i_see_validation_errors expect(page).to have_content("Choose a programme") expect(page).to have_content("Choose a file format") end + + def then_i_download_a_careplus_file + expect(page.status_code).to eq(200) + + expect(page).to have_content( + "NHS Number,Surname,Forename,Date of Birth,Address Line 1" + ) + end end diff --git a/spec/features/download_vaccination_reports_spec.rb b/spec/features/download_vaccination_reports_spec.rb index 195c4cbf87..4abcecf666 100644 --- a/spec/features/download_vaccination_reports_spec.rb +++ b/spec/features/download_vaccination_reports_spec.rb @@ -2,7 +2,7 @@ describe "Download vaccination reports" do scenario "Download in CarePlus format" do - given_an_hpv_programme_is_underway + given_an_hpv_programme_is_underway_and_care_plus_is_enabled and_an_administered_vaccination_record_exists when_i_go_to_the_programme @@ -99,6 +99,17 @@ def given_a_menacwy_programme_is_underway create(:patient_location, patient: @patient, session: @session) end + def given_an_hpv_programme_is_underway_and_care_plus_is_enabled + given_an_hpv_programme_is_underway + @team = + create( + :team, + :with_one_nurse, + :with_careplus_enabled, + programmes: [@programme] + ) + end + def and_an_administered_vaccination_record_exists vaccine = @programme.vaccines.first diff --git a/spec/features/e2e_journey_spec.rb b/spec/features/e2e_journey_spec.rb index fa0a1dce16..e529be7650 100644 --- a/spec/features/e2e_journey_spec.rb +++ b/spec/features/e2e_journey_spec.rb @@ -94,7 +94,10 @@ def and_the_default_navigation_items navigation_items = page.all(".nhsuk-header__navigation-item") expect(navigation_items.count).to eq(9) expect(navigation_items[0]).to have_link("Schools", href: schools_path) - expect(navigation_items[8]).to have_link("Team", href: team_path) + expect(navigation_items[8]).to have_link( + "Team", + href: contact_details_team_path + ) end def and_the_default_service_name diff --git a/spec/features/edit_session_dates_spec.rb b/spec/features/edit_session_dates_spec.rb index a825041635..1696c97815 100644 --- a/spec/features/edit_session_dates_spec.rb +++ b/spec/features/edit_session_dates_spec.rb @@ -41,7 +41,7 @@ def and_the_session_has_unvaccinated_catch_up_patients create_list( :patient, 9, - :eligible_for_vaccination, + :consent_no_response, session: @session, year_group: 9 ) diff --git a/spec/features/edit_session_programmes_spec.rb b/spec/features/edit_session_programmes_spec.rb index 72fc3f0015..9aaad33e56 100644 --- a/spec/features/edit_session_programmes_spec.rb +++ b/spec/features/edit_session_programmes_spec.rb @@ -86,7 +86,7 @@ def and_the_school_has_unvaccinated_catch_up_patients create_list( :patient, 2, - :eligible_for_vaccination, + :consent_no_response, location: @location, year_group: 9, programmes: @programmes diff --git a/spec/features/edit_vaccination_record_spec.rb b/spec/features/edit_vaccination_record_spec.rb index ec68cd6bfe..a219469230 100644 --- a/spec/features/edit_vaccination_record_spec.rb +++ b/spec/features/edit_vaccination_record_spec.rb @@ -371,6 +371,40 @@ then_i_should_see_the_vaccination_record end + scenario "Edits the batch" do + given_i_am_signed_in + and_a_bulk_uploaded_vaccination_record_exists + + when_i_navigate_to_the_edit_vaccination_record_page + + when_i_click_on_change_batch + then_i_should_see_the_batch_form + + when_i_click_back + and_i_click_on_change_vaccine + then_i_should_see_the_batch_form + + when_i_click_back + and_i_click_on_change_batch_expiry_date + then_i_should_see_the_batch_form + + when_i_enter_an_empty_batch_name + then_i_should_see_the_batch_form + and_i_should_see_an_error_message_for_batch_name + + when_i_enter_an_empty_day + then_i_should_see_the_batch_form + and_i_should_see_an_error_message_for_day + + when_i_enter_batch_details + then_i_see_the_edit_vaccination_record_page + and_i_should_see_the_national_reporting_updated_batch + + when_i_click_on_save_changes + then_i_should_see_the_vaccination_record + and_the_batch_should_be_a_new_batch_object + end + scenario "Parent details are not visible when viewing vaccination records" do given_i_am_signed_in and_a_bulk_uploaded_vaccination_record_exists @@ -459,8 +493,15 @@ def given_a_bulk_upload_team_exists @school = create(:school, name: "A New School", status: "open") @vaccine = @programme.vaccines.first + @new_vaccine = @programme.vaccines.second - @batch = create(:batch, team: @team, vaccine: @vaccine) + @batch = + create( + :batch, + team: @team, + vaccine: @vaccine, + expiry: Date.new(2026, 1, 1) + ) end def given_i_am_signed_in @@ -491,6 +532,7 @@ def and_a_bulk_uploaded_vaccination_record_exists :sourced_from_bulk_upload, uploaded_by: @team.users.first, batch: @batch, + vaccine: @batch.vaccine, patient: @patient, programme: @programme, performed_by_user: nil, @@ -810,6 +852,65 @@ def and_i_should_see_the_new_notes expect(page).to have_content("NotesSome notes.") end + def when_i_click_on_change_batch + click_on "Change batch" + end + + def and_i_click_on_change_vaccine + click_on "Change vaccine" + end + + def and_i_click_on_change_batch_expiry_date + click_on "Change batch expiry date" + end + + def then_i_should_see_the_batch_form + expect(page).to have_content("Which vaccine and batch did you use?") + expect(page).to have_content("Vaccine") + expect(page).to have_content("Batch number") + expect(page).to have_content("Batch expiry date") + end + + def when_i_enter_an_empty_batch_name + fill_in "Batch number", with: "" + + click_on "Continue" + end + + def and_i_should_see_an_error_message_for_batch_name + expect(page).to have_content("Enter a batch number") + end + + def when_i_enter_an_empty_day + fill_in "Day", with: "" + + click_on "Continue" + end + + def and_i_should_see_an_error_message_for_day + expect(page).to have_content("Enter a day") + end + + def when_i_enter_batch_details + choose @new_vaccine.nivs_name + fill_in "Batch number", with: "NEWBATCH123" + fill_in "Day", with: "1" + fill_in "Month", with: "12" + fill_in "Year", with: "2027" + + click_on "Continue" + end + + def and_i_should_see_the_national_reporting_updated_batch + expect(page).to have_content("Vaccine#{@new_vaccine.nivs_name}") + expect(page).to have_content("Batch numberNEWBATCH123") + expect(page).to have_content("Batch expiry date1 December 2027") + end + + def and_the_batch_should_be_a_new_batch_object + expect(@vaccination_record.reload.batch).not_to eq(@batch) + end + def when_i_click_on_save_changes travel 1.minute click_on "Save changes" diff --git a/spec/features/filtering_by_eligible_children_spec.rb b/spec/features/filtering_by_eligible_children_spec.rb index 64039772b7..89ad4c733f 100644 --- a/spec/features/filtering_by_eligible_children_spec.rb +++ b/spec/features/filtering_by_eligible_children_spec.rb @@ -23,8 +23,8 @@ def and_patients_are_in_the_session_included_a_deceased_patient @patient_ineligible = create(:patient, year_group: 9, session: @session) create( - :patient_vaccination_status, - :vaccinated, + :patient_programme_status, + :vaccinated_fully, patient: @patient_ineligible, programme: @programme, academic_year: AcademicYear.current - 1 diff --git a/spec/features/import_vaccination_records_bulk_spec.rb b/spec/features/import_vaccination_records_bulk_spec.rb index 439ec33cb0..ae968ec5e0 100644 --- a/spec/features/import_vaccination_records_bulk_spec.rb +++ b/spec/features/import_vaccination_records_bulk_spec.rb @@ -11,6 +11,7 @@ when_i_go_to_the_import_page then_i_should_see_the_upload_link + and_i_should_not_see_any_reference_to_import_issues when_i_click_on_the_upload_link then_i_should_see_the_upload_page @@ -102,6 +103,17 @@ def then_i_should_see_the_upload_link expect(page).to have_button("Upload records") end + def and_i_should_not_see_any_reference_to_import_issues + expect(page).not_to have_content( + "Any close matches to resolve will appear in the Issues tab." + ) + + navigation_items = page.all(".app-secondary-navigation__link") + expect(navigation_items.map(&:text)).not_to include( + a_string_including("Issues") + ) + end + def when_i_click_on_the_upload_link click_on "Upload records" end diff --git a/spec/features/manage_teams_spec.rb b/spec/features/manage_teams_spec.rb index 97d15cef6d..5f4d2489e9 100644 --- a/spec/features/manage_teams_spec.rb +++ b/spec/features/manage_teams_spec.rb @@ -5,11 +5,22 @@ given_my_team_exists when_i_click_on_team_settings - then_i_see_the_team_settings + then_i_see_the_team_contact_details + + when_i_click_on_clinics + then_i_see_the_team_clinics + + when_i_click_on_schools + then_i_see_the_team_schools + + when_i_click_on_sessions + then_i_see_the_team_sessions end def given_my_team_exists @team = create(:team, :with_one_nurse) + create(:school, team: @team) + create(:community_clinic, team: @team) end def when_i_click_on_team_settings @@ -19,8 +30,35 @@ def when_i_click_on_team_settings click_on "Your team", match: :first end - def then_i_see_the_team_settings + def then_i_see_the_team_contact_details expect(page).to have_content("Contact details") + end + + def when_i_click_on_clinics + click_on "Clinics" + end + + def then_i_see_the_team_clinics + expect(page).to have_content("Clinics") + expect(page).to have_content(@team.community_clinics.first.name) + expect(page).to have_content(@team.community_clinics.first.address_line_1) + end + + def when_i_click_on_schools + find(".app-sub-navigation__link", text: "Schools").click + end + + def then_i_see_the_team_schools + expect(page).to have_content("Schools") + expect(page).to have_content(@team.schools.first.name) + expect(page).to have_content(@team.schools.first.address_line_1) + end + + def when_i_click_on_sessions + find(".app-sub-navigation__link", text: "Sessions").click + end + + def then_i_see_the_team_sessions expect(page).to have_content("Session defaults") end end diff --git a/spec/features/scheduled_consent_requests_spec.rb b/spec/features/scheduled_consent_requests_spec.rb index c4f007abc9..7ffd187dc2 100644 --- a/spec/features/scheduled_consent_requests_spec.rb +++ b/spec/features/scheduled_consent_requests_spec.rb @@ -9,6 +9,7 @@ and_i_am_signed_in when_i_go_to_my_team_page + and_i_click_on_sessions then_i_see_consent_requests_are_sent_3_weeks_before when_i_schedule_a_session_4_weeks_away @@ -70,6 +71,10 @@ def when_i_go_to_my_team_page click_link "Your team", match: :first end + def and_i_click_on_sessions + find(".app-sub-navigation__link", text: "Sessions").click + end + def then_i_see_consent_requests_are_sent_3_weeks_before expect(page).to have_content( ["Consent requests", "Send 3 weeks before first session"].join @@ -77,7 +82,7 @@ def then_i_see_consent_requests_are_sent_3_weeks_before end def when_i_schedule_a_session_4_weeks_away - click_link "Sessions" + click_link "Sessions", match: :first choose "Unscheduled" click_button "Update results" click_link @location.name diff --git a/spec/features/tallying_session_overview_spec.rb b/spec/features/tallying_session_overview_spec.rb index 5218a776f9..b4b96f9d61 100644 --- a/spec/features/tallying_session_overview_spec.rb +++ b/spec/features/tallying_session_overview_spec.rb @@ -102,7 +102,8 @@ def and_one_vaccinated :patient_programme_status, :vaccinated_fully, patient: @patients.fifth, - programme: @flu_programme + programme: @flu_programme, + location: @session.location ) end diff --git a/spec/features/upload_only_team_spec.rb b/spec/features/upload_only_team_spec.rb index c2e75419af..7a24598417 100644 --- a/spec/features/upload_only_team_spec.rb +++ b/spec/features/upload_only_team_spec.rb @@ -13,7 +13,8 @@ scenario "Navigation shows only import, children and your team" do given_i_am_signed_in_as_an_upload_only_team when_i_visit_the_dashboard - then_i_should_see_only_import_children_and_team_navigation_items + then_i_should_see_only_import_and_children_navigation_items + and_there_should_be_no_count_next_to_the_import_link end scenario "Children page search shows limited filters and the patient's card" do @@ -110,13 +111,17 @@ def and_i_should_see_the_children_card expect(card).to have_link("Children", href: patients_path) end - def then_i_should_see_only_import_children_and_team_navigation_items + def then_i_should_see_only_import_and_children_navigation_items navigation_items = page.all(".nhsuk-header__navigation-item") expect(navigation_items.count).to eq(2) expect(navigation_items[0]).to have_link("Imports", href: imports_path) expect(navigation_items[1]).to have_link("Children", href: patients_path) end + def and_there_should_be_no_count_next_to_the_import_link + expect(page).not_to have_css(".app-count", text: "(0)") + end + def when_i_visit_the_children_page visit patients_path end diff --git a/spec/fixtures/files/onboarding/valid.yaml b/spec/fixtures/files/onboarding/valid.yaml index cdf2e3ff8c..30de764c68 100644 --- a/spec/fixtures/files/onboarding/valid.yaml +++ b/spec/fixtures/files/onboarding/valid.yaml @@ -6,6 +6,8 @@ team: email: example@trust.nhs.uk phone: 07700 900815 phone_instructions: option 1, followed by option 3 + careplus_staff_code: ABCD + careplus_staff_type: PQ careplus_venue_code: EXAMPLE privacy_notice_url: https://example.com/privacy-notice privacy_policy_url: https://example.com/privacy-policy diff --git a/spec/jobs/send_automatic_school_consent_reminders_job_spec.rb b/spec/jobs/send_automatic_school_consent_reminders_job_spec.rb index 0e0ff1a0cf..b45e8ab8dd 100644 --- a/spec/jobs/send_automatic_school_consent_reminders_job_spec.rb +++ b/spec/jobs/send_automatic_school_consent_reminders_job_spec.rb @@ -15,7 +15,7 @@ :patient, :consent_request_sent, :initial_consent_reminder_sent, - :eligible_for_vaccination, + :consent_no_response, parents:, programmes: ) @@ -24,7 +24,7 @@ create( :patient, :consent_request_sent, - :eligible_for_vaccination, + :consent_no_response, parents:, programmes: ) @@ -36,14 +36,14 @@ create( :patient, :consent_request_sent, - :eligible_for_vaccination, + :consent_no_response, parents:, programmes: ) end let(:patient_not_sent_request) do - create(:patient, :eligible_for_vaccination, parents:, programmes:) + create(:patient, :consent_no_response, parents:, programmes:) end let(:patient_with_consent) do create(:patient, :consent_given_triage_not_needed, programmes:) diff --git a/spec/jobs/send_clinic_subsequent_invitations_job_spec.rb b/spec/jobs/send_clinic_subsequent_invitations_job_spec.rb index b5690944e7..7178b9b799 100644 --- a/spec/jobs/send_clinic_subsequent_invitations_job_spec.rb +++ b/spec/jobs/send_clinic_subsequent_invitations_job_spec.rb @@ -46,8 +46,8 @@ context "when already vaccinated" do before do create( - :patient_vaccination_status, - :vaccinated, + :patient_programme_status, + :vaccinated_fully, patient:, programme: programmes.first ) diff --git a/spec/jobs/send_school_consent_requests_job_spec.rb b/spec/jobs/send_school_consent_requests_job_spec.rb index 6a1433fb3a..c958a7bf4e 100644 --- a/spec/jobs/send_school_consent_requests_job_spec.rb +++ b/spec/jobs/send_school_consent_requests_job_spec.rb @@ -7,15 +7,10 @@ let(:programmes) { [Programme.sample] } let(:parents) { create_list(:parent, 2) } let(:patient_with_request_sent) do - create( - :patient, - :eligible_for_vaccination, - :consent_request_sent, - programmes: - ) + create(:patient, :consent_no_response, :consent_request_sent, programmes:) end let(:patient_not_sent_request) do - create(:patient, :eligible_for_vaccination, parents:, programmes:) + create(:patient, :consent_no_response, parents:, programmes:) end let(:patient_with_consent) do create(:patient, :consent_given_triage_not_needed, programmes:) @@ -97,14 +92,7 @@ create(:patient, year_group: 8, parents:, programmes:) end - before do - create( - :patient_vaccination_status, - :eligible, - patient: patient_not_sent_request, - programme: hpv_programme - ) - end + before { StatusUpdater.call(patient: patient_not_sent_request) } it "sends only one notification for HPV" do expect(ConsentNotification).to receive(:create_and_send!).once.with( @@ -120,15 +108,11 @@ context "when the patient is in Year 9" do let(:patient_not_sent_request) do - create( - :patient, - :eligible_for_vaccination, - year_group: 9, - parents:, - programmes: - ) + create(:patient, year_group: 9, parents:, programmes:) end + before { StatusUpdater.call(patient: patient_not_sent_request) } + it "sends two notifications for HPV, and MenACWY and Td/IPV" do expect(ConsentNotification).to receive(:create_and_send!).with( patient: patient_not_sent_request, diff --git a/spec/lib/fhir_mapper/vaccination_record_spec.rb b/spec/lib/fhir_mapper/vaccination_record_spec.rb index e0550a046e..deb54f9117 100644 --- a/spec/lib/fhir_mapper/vaccination_record_spec.rb +++ b/spec/lib/fhir_mapper/vaccination_record_spec.rb @@ -62,9 +62,32 @@ end describe "contained performing practitioner" do - subject { immunisation_fhir.contained.find { it.id == "Practitioner1" } } + subject(:contained_practitioner) do + immunisation_fhir.contained.find { it.id == "Practitioner1" } + end it { should eq user.fhir_practitioner(reference_id: "Practitioner1") } + + context "when the performing professional is not a local user" do + subject(:name) { contained_practitioner.name.first } + + before do + vaccination_record.update( + performed_by_user: nil, + performed_by_given_name: "John", + performed_by_family_name: "Howie" + ) + end + + its(:given) { should eq ["John"] } + its(:family) { should eq "Howie" } + end + + context "when the performing professional is missing" do + before { vaccination_record.update(performed_by_user: nil) } + + it { should be_nil } + end end describe "performing practitioner" do diff --git a/spec/lib/patient_programme_status_resolver_spec.rb b/spec/lib/patient_programme_status_resolver_spec.rb new file mode 100644 index 0000000000..cb5b15def7 --- /dev/null +++ b/spec/lib/patient_programme_status_resolver_spec.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +describe PatientProgrammeStatusResolver do + subject(:hash) do + described_class.call( + Patient.includes_statuses.find(patient.id), + programme_type: programme.type, + academic_year:, + context_location_id: + ) + end + + let(:patient) { create(:patient) } + let(:programme) { Programme.hpv } + let(:session) { create(:session, programmes: [programme]) } + let(:academic_year) { AcademicYear.current } + let(:context_location_id) { nil } + + before { Flipper.enable(:mmrv) } + + it { should eq({ prefix: "HPV", text: "Not eligible", colour: "grey" }) } + + context "when triaged to delay vaccination" do + around { |example| freeze_time(Date.new(2025, 10, 29)) { example.run } } + + let(:patient) do + create(:patient, :consent_given_triage_delay_vaccination, session:) + end + + it do + expect(hash).to eq( + { + prefix: "HPV", + text: "Unable to vaccinate", + colour: "red", + details_text: "Delay vaccination until 30 October 2025" + } + ) + end + end + + context "with an administered vaccination record" do + let(:patient) do + create(:patient, :consent_given_triage_not_needed, session:) + end + + before do + create( + :vaccination_record, + :administered, + patient:, + programme:, + performed_at: Time.zone.local(2025, 10, 30) + ) + StatusUpdater.call(patient:) + patient.reload + end + + it do + expect(hash).to eq( + { + prefix: "HPV", + text: "Vaccinated", + colour: "white", + details_text: "Vaccinated on 30 October 2025" + } + ) + end + end + + context "with an already had vaccination record" do + let(:patient) do + create(:patient, :consent_given_triage_not_needed, session:) + end + + before do + create(:vaccination_record, :already_had, patient:, programme:) + StatusUpdater.call(patient:) + patient.reload + end + + it do + expect(hash).to eq( + { + prefix: "HPV", + text: "Vaccinated", + colour: "white", + details_text: "Already had the vaccine" + } + ) + end + end + + context "and due" do + let(:patient) do + create(:patient, :consent_given_triage_not_needed, session:) + end + + it do + expect(hash).to eq( + { prefix: "HPV", text: "Due vaccination", colour: "green" } + ) + end + end + + context "for MMR programme" do + let(:programme) { Programme.mmr } + let(:programme_variant) do + programme.variant_for( + disease_types: Programme::Variant::DISEASE_TYPES.fetch("mmr") + ) + end + + let(:date_of_birth) { Date.new(2019, 12, 31) } + + context "and eligible for 1st dose" do + let(:patient) { create(:patient, date_of_birth:, session:) } + + before do + StatusUpdater.call(patient:) + patient.reload + end + + it do + expect(hash).to eq( + { + prefix: "MMR", + text: "Needs consent", + colour: "blue", + details_text: "No response" + } + ) + end + end + + context "and due 1st dose" do + let(:patient) do + create( + :patient, + :consent_given_triage_not_needed, + date_of_birth:, + session:, + programmes: [programme_variant] + ) + end + + before do + StatusUpdater.call(patient:) + patient.reload + end + + it do + expect(hash).to eq( + { + prefix: "MMR", + text: "Due 1st dose", + colour: "green", + details_text: "No preference" + } + ) + end + end + + context "and due 1st dose gelatine-free" do + let(:patient) do + create( + :patient, + :consent_given_without_gelatine_triage_not_needed, + date_of_birth:, + session:, + programmes: [programme_variant] + ) + end + + before do + StatusUpdater.call(patient:) + patient.reload + end + + it do + expect(hash).to eq( + { + prefix: "MMR", + text: "Due 1st dose", + colour: "green", + details_text: "Gelatine-free vaccine only" + } + ) + end + end + end +end diff --git a/spec/lib/patient_status_resolver_spec.rb b/spec/lib/patient_status_resolver_spec.rb deleted file mode 100644 index 0c60171bfa..0000000000 --- a/spec/lib/patient_status_resolver_spec.rb +++ /dev/null @@ -1,217 +0,0 @@ -# frozen_string_literal: true - -describe PatientStatusResolver do - subject(:patient_status_resolver) do - described_class.new( - Patient.includes_statuses.find(patient.id), - programme:, - academic_year:, - context_location_id: - ) - end - - let(:patient) { create(:patient) } - let(:academic_year) { AcademicYear.current } - let(:context_location_id) { nil } - - before { Flipper.enable(:mmrv) } - - describe "#consent" do - subject { patient_status_resolver.consent } - - let(:programme) { Programme.hpv } - - it { should eq({ prefix: "HPV", text: "No response", colour: "grey" }) } - end - - describe "#programme" do - subject(:hash) { patient_status_resolver.programme } - - let(:programme) { Programme.hpv } - let(:session) { create(:session, programmes: [programme]) } - - it { should eq({ prefix: "HPV", text: "Not eligible", colour: "grey" }) } - - context "when triaged to delay vaccination" do - around { |example| freeze_time(Date.new(2025, 10, 29)) { example.run } } - - let(:patient) do - create(:patient, :consent_given_triage_delay_vaccination, session:) - end - - it do - expect(hash).to eq( - { - prefix: "HPV", - text: "Unable to vaccinate", - colour: "red", - details_text: "Delay vaccination until 30 October 2025" - } - ) - end - end - - context "with an administered vaccination record" do - let(:patient) do - create(:patient, :consent_given_triage_not_needed, session:) - end - - before do - create( - :vaccination_record, - :administered, - patient:, - programme:, - performed_at: Time.zone.local(2025, 10, 30) - ) - StatusUpdater.call(patient:) - patient.reload - end - - it do - expect(hash).to eq( - { - prefix: "HPV", - text: "Vaccinated", - colour: "white", - details_text: "Vaccinated on 30 October 2025" - } - ) - end - end - - context "with an already had vaccination record" do - let(:patient) do - create(:patient, :consent_given_triage_not_needed, session:) - end - - before do - create(:vaccination_record, :already_had, patient:, programme:) - StatusUpdater.call(patient:) - patient.reload - end - - it do - expect(hash).to eq( - { - prefix: "HPV", - text: "Vaccinated", - colour: "white", - details_text: "Already had the vaccine" - } - ) - end - end - - context "and due" do - let(:patient) do - create(:patient, :consent_given_triage_not_needed, session:) - end - - it do - expect(hash).to eq( - { prefix: "HPV", text: "Due vaccination", colour: "green" } - ) - end - end - - context "for MMR programme" do - let(:programme) { Programme.mmr } - let(:programme_variant) do - programme.variant_for( - disease_types: Programme::Variant::DISEASE_TYPES.fetch("mmr") - ) - end - - let(:date_of_birth) { Date.new(2019, 12, 31) } - - context "and eligible for 1st dose" do - let(:patient) { create(:patient, date_of_birth:, session:) } - - before do - StatusUpdater.call(patient:) - patient.reload - end - - it do - expect(hash).to eq( - { - prefix: "MMR", - text: "Needs consent", - colour: "blue", - details_text: "No response" - } - ) - end - end - - context "and due 1st dose" do - let(:patient) do - create( - :patient, - :consent_given_triage_not_needed, - date_of_birth:, - session:, - programmes: [programme_variant] - ) - end - - before do - StatusUpdater.call(patient:) - patient.reload - end - - it do - expect(hash).to eq( - { - prefix: "MMR", - text: "Due 1st dose", - colour: "green", - details_text: "No preference" - } - ) - end - end - - context "and due 1st dose gelatine-free" do - let(:patient) do - create( - :patient, - :consent_given_without_gelatine_triage_not_needed, - date_of_birth:, - session:, - programmes: [programme_variant] - ) - end - - before do - StatusUpdater.call(patient:) - patient.reload - end - - it do - expect(hash).to eq( - { - prefix: "MMR", - text: "Due 1st dose", - colour: "green", - details_text: "Gelatine-free vaccine only" - } - ) - end - end - end - end - - describe "#triage" do - subject(:hash) { patient_status_resolver.triage } - - let(:programme) { Programme.hpv } - - it do - expect(hash).to eq( - { prefix: "HPV", text: "No triage needed", colour: "grey" } - ) - end - end -end diff --git a/spec/lib/patient_team_updater_spec.rb b/spec/lib/patient_team_updater_spec.rb index ec0af060e1..1a3947776c 100644 --- a/spec/lib/patient_team_updater_spec.rb +++ b/spec/lib/patient_team_updater_spec.rb @@ -5,8 +5,6 @@ context "with an archive reason" do before do create(:archive_reason, :imported_in_error, patient:, team:) - - # We need to do this because callbacks create them automatically. PatientTeam.delete_all end @@ -20,8 +18,6 @@ context "with a patient location" do before do create(:patient_location, patient:, session: create(:session, team:)) - - # We need to do this because callbacks create them automatically. PatientTeam.delete_all end @@ -40,8 +36,6 @@ patient:, school: create(:school, team:) ) - - # We need to do this because callbacks create them automatically. PatientTeam.delete_all end @@ -57,8 +51,6 @@ context "with a school move by team" do before do create(:school_move, :to_home_educated, patient:, team:) - - # We need to do this because callbacks create them automatically. PatientTeam.delete_all end @@ -77,8 +69,6 @@ team: nil, immunisation_imports: [create(:immunisation_import, team:)] ) - - # We need to do this because callbacks create them automatically. PatientTeam.delete_all end @@ -94,8 +84,6 @@ context "with a vaccination record by organisation" do before do create(:vaccination_record, patient:, team:) - - # We need to do this because callbacks create them automatically. PatientTeam.delete_all end @@ -116,8 +104,6 @@ team: nil, session: create(:session, team:) ) - - # We need to do this because callbacks create them automatically. PatientTeam.delete_all end @@ -134,8 +120,6 @@ before do create(:archive_reason, :imported_in_error, patient:, team:) create(:patient_location, patient:, session: create(:session, team:)) - - # We need to do this because callbacks create them automatically. PatientTeam.delete_all end diff --git a/spec/lib/reports/careplus_exporter_spec.rb b/spec/lib/reports/careplus_exporter_spec.rb index 9868b842c3..60bab42609 100644 --- a/spec/lib/reports/careplus_exporter_spec.rb +++ b/spec/lib/reports/careplus_exporter_spec.rb @@ -17,7 +17,15 @@ shared_examples "generates a report" do let(:programmes) { [programme] } - let(:team) { create(:team, careplus_venue_code: "ABC", programmes:) } + let(:team) do + create( + :team, + careplus_staff_code: "ABCD", + careplus_staff_type: "PQ", + careplus_venue_code: "ABC", + programmes: + ) + end let(:location) do create( :school, @@ -110,8 +118,8 @@ expect(row[dose_index]).to eq("1P") expect(row[batch_index]).to eq(vaccination_record.batch.name) expect(row[site_index]).to eq("ULA") - expect(row[staff_type_index]).to eq("IN") - expect(row[staff_code_index]).to eq("LW5PM") + expect(row[staff_type_index]).to eq("PQ") + expect(row[staff_code_index]).to eq("ABCD") expect(row[venue_type_index]).to eq("SC") expect(row[venue_code_index]).to eq("123456") end diff --git a/spec/lib/stats/session_spec.rb b/spec/lib/stats/session_spec.rb index 676dac29df..1f07bc3de4 100644 --- a/spec/lib/stats/session_spec.rb +++ b/spec/lib/stats/session_spec.rb @@ -6,7 +6,7 @@ let(:programme) { Programme.hpv } let(:session) { create(:session, programmes: [programme]) } - let(:latest_location) { session.location } + let(:location) { session.location } context "with no patients" do it "returns zero counts for all stats" do @@ -45,18 +45,12 @@ end create(:patient, session:, year_group: 9).tap do |patient| - create( - :patient_vaccination_status, - :vaccinated, - patient:, - programme:, - latest_location: - ) create( :patient_programme_status, :vaccinated_fully, patient:, - programme: + programme:, + location: ) end @@ -251,11 +245,11 @@ before do create(:patient, session:, year_group: 9).tap do |patient| create( - :patient_vaccination_status, - :vaccinated, + :patient_programme_status, + :vaccinated_fully, patient:, programme:, - latest_location: nil + location: nil ) end end diff --git a/spec/lib/status_generator/consent_spec.rb b/spec/lib/status_generator/consent_spec.rb index bb0143e7f0..dd483a0a9f 100644 --- a/spec/lib/status_generator/consent_spec.rb +++ b/spec/lib/status_generator/consent_spec.rb @@ -3,7 +3,7 @@ describe StatusGenerator::Consent do subject(:generator) do described_class.new( - programme:, + programme_type: programme.type, academic_year: AcademicYear.current, patient:, consents: patient.consents, diff --git a/spec/lib/status_generator/programme_spec.rb b/spec/lib/status_generator/programme_spec.rb index 54bacea8d1..83e5c7f2c8 100644 --- a/spec/lib/status_generator/programme_spec.rb +++ b/spec/lib/status_generator/programme_spec.rb @@ -3,7 +3,7 @@ describe StatusGenerator::Programme do subject(:generator) do described_class.new( - programme:, + programme_type: programme.type, academic_year: AcademicYear.current, patient:, patient_locations: @@ -64,7 +64,7 @@ its(:date) { should eq(vaccination_record.performed_at.to_date) } its(:disease_types) { should be_nil } - its(:dose_sequence) { should be_nil } + its(:dose_sequence) { should eq(2) } its(:location_id) { should be_nil } its(:status) { should be(:needs_consent_no_response) } its(:vaccine_methods) { should be_nil } @@ -194,6 +194,8 @@ end context "when triaged as delay" do + let(:programme) { Programme.menacwy } + before do create(:consent, :given, patient:, programme:) create( @@ -215,6 +217,8 @@ end context "when triaged as invite to clinic" do + let(:programme) { Programme.hpv } + before do create(:consent, :given, patient:, programme:) create(:triage, :invite_to_clinic, patient:, programme:) @@ -230,6 +234,8 @@ end context "when triaged as do not vaccinated" do + let(:programme) { Programme.td_ipv } + before do create(:consent, :given, patient:, programme:) create(:triage, :do_not_vaccinate, patient:, programme:) @@ -245,6 +251,8 @@ end context "when needs triage" do + let(:programme) { Programme.flu } + before { create(:consent, :given, :needing_triage, patient:, programme:) } its(:date) { should be_nil } @@ -257,11 +265,13 @@ end context "when consent is refused" do + let(:programme) { Programme.mmr } + before { create(:consent, :refused, patient:, programme:) } its(:date) { should be_nil } its(:disease_types) { should be_empty } - its(:dose_sequence) { should be_nil } + its(:dose_sequence) { should eq(1) } its(:location_id) { should be_nil } its(:status) { should be(:has_refusal_consent_refused) } its(:vaccine_methods) { should be_nil } @@ -269,6 +279,8 @@ end context "when consent is conflicting" do + let(:programme) { Programme.mmr } + before do create(:consent, :refused, patient:, programme:) create(:consent, :given, patient:, programme:, parent: create(:parent)) @@ -276,7 +288,7 @@ its(:date) { should be_nil } its(:disease_types) { should be_empty } - its(:dose_sequence) { should be_nil } + its(:dose_sequence) { should be(1) } its(:location_id) { should be_nil } its(:status) { should be(:has_refusal_consent_conflicts) } its(:vaccine_methods) { should be_nil } @@ -284,6 +296,8 @@ end context "when consent is needed" do + let(:programme) { Programme.menacwy } + its(:date) { should be_nil } its(:disease_types) { should be_nil } its(:dose_sequence) { should be_nil } @@ -291,6 +305,12 @@ its(:status) { should be(:needs_consent_no_response) } its(:vaccine_methods) { should be_nil } its(:without_gelatine) { should be_nil } + + context "with a multi-dose programme" do + let(:programme) { Programme.mmr } + + its(:dose_sequence) { should eq(1) } + end end context "when not eligible" do diff --git a/spec/lib/status_generator/triage_spec.rb b/spec/lib/status_generator/triage_spec.rb index b0fec21c2e..d56dc7b097 100644 --- a/spec/lib/status_generator/triage_spec.rb +++ b/spec/lib/status_generator/triage_spec.rb @@ -3,7 +3,7 @@ describe StatusGenerator::Triage do subject(:generator) do described_class.new( - programme:, + programme_type: programme.type, academic_year: AcademicYear.current, patient:, consents: patient.consents, diff --git a/spec/lib/status_generator/vaccination_spec.rb b/spec/lib/status_generator/vaccination_spec.rb index db90ec2c8f..6cc3c55f91 100644 --- a/spec/lib/status_generator/vaccination_spec.rb +++ b/spec/lib/status_generator/vaccination_spec.rb @@ -3,7 +3,7 @@ describe StatusGenerator::Vaccination do subject(:generator) do described_class.new( - programme:, + programme_type: programme.type, academic_year: AcademicYear.current, patient:, patient_locations: @@ -345,7 +345,16 @@ :vaccination_record, patient:, programme:, - performed_at: patient.date_of_birth + 1.year + 3.months + performed_at: + (patient.date_of_birth + 1.year + 3.months).then do + # When date is at the end of the month, adding months to it + # results in dates that are also at the end of the month, + # which can cause test failures. For example if dob is + # 2021-01-31, then 1 year + 3 months is 2022-04-30, but when + # AgeConcern#age_months calculates age, the result is 14 + # months, triggering a false negative in our test here. + it == it.end_of_month ? it + 1.day : it + end ) end diff --git a/spec/lib/status_updater_spec.rb b/spec/lib/status_updater_spec.rb index c2e183e0dc..4247dbfd1a 100644 --- a/spec/lib/status_updater_spec.rb +++ b/spec/lib/status_updater_spec.rb @@ -29,10 +29,6 @@ it "doesn't create any triage statuses" do expect { call }.not_to change(Patient::TriageStatus, :count) end - - it "doesn't create any vaccination statuses" do - expect { call }.not_to change(Patient::VaccinationStatus, :count) - end end context "with an flu session and eligible patient" do @@ -66,11 +62,6 @@ expect { call }.to change(patient.triage_statuses, :count).by(1) expect(patient.triage_statuses.first).to be_not_required end - - it "creates a vaccination status" do - expect { call }.to change(patient.vaccination_statuses, :count).by(1) - expect(patient.vaccination_statuses.first).to be_eligible - end end context "with an HPV session and eligible patient" do @@ -95,11 +86,6 @@ expect { call }.to change(patient.triage_statuses, :count).by(1) expect(patient.triage_statuses.first).to be_not_required end - - it "creates a vaccination status" do - expect { call }.to change(patient.vaccination_statuses, :count).by(1) - expect(patient.vaccination_statuses.first).to be_eligible - end end context "with a doubles session and ineligible patient" do @@ -121,10 +107,6 @@ it "doesn't create any triage statuses" do expect { call }.not_to change(Patient::TriageStatus, :count) end - - it "doesn't create any vaccination statuses" do - expect { call }.not_to change(Patient::VaccinationStatus, :count) - end end context "with an doubles session and eligible patient" do @@ -151,11 +133,5 @@ expect(patient.triage_statuses.first).to be_not_required expect(patient.triage_statuses.second).to be_not_required end - - it "creates a patient vaccination status for both programmes" do - expect { call }.to change(patient.vaccination_statuses, :count).by(2) - expect(patient.vaccination_statuses.first).to be_eligible - expect(patient.vaccination_statuses.second).to be_eligible - end end end diff --git a/spec/models/immunisation_import_row_spec.rb b/spec/models/immunisation_import_row_spec.rb index e427375d23..6d6e82a46e 100644 --- a/spec/models/immunisation_import_row_spec.rb +++ b/spec/models/immunisation_import_row_spec.rb @@ -15,7 +15,7 @@ end let(:programmes) { [Programme.hpv] } - let(:team) { create(:team, ods_code: "abc", programmes:) } + let(:team) { create(:team, ods_code: "ABC", programmes:) } let(:nhs_number) { "9449306168" } let(:given_name) { "Harry" } @@ -25,7 +25,7 @@ let(:vaccinator) { create(:user, team:) } let(:valid_patient_data) do { - "ORGANISATION_CODE" => "abc", + "ORGANISATION_CODE" => "ABC", "SCHOOL_NAME" => "Hogwarts", "SCHOOL_URN" => "123456", "PERSON_FORENAME" => given_name, @@ -2417,7 +2417,7 @@ let(:import_type) { "bulk" } - shared_examples "with existing vaccination records" do |programme| + shared_examples "date deduplication with existing vaccination records" do |programme| let(:patient) { create(:patient, nhs_number:) } let(:other_vaccination_record) do @@ -2429,7 +2429,9 @@ before { other_vaccination_record } + it { should_not be_nil } it { should_not eq other_vaccination_record } + its(:pending_changes) { should be_empty } end context "with an existing vaccination record on a different date in the same academic year" do @@ -2437,7 +2439,9 @@ before { other_vaccination_record } + it { should_not be_nil } it { should_not eq other_vaccination_record } + its(:pending_changes) { should be_empty } end context "with an existing vaccination record on the same date" do @@ -2466,9 +2470,11 @@ let(:data) { valid_bulk_flu_data } + let(:programme) { Programme.flu } + it { should be_administered } - its(:programme) { should eq(Programme.flu) } + its(:programme) { should eq programme } its(:source) { should eq("bulk_upload") } @@ -2552,7 +2558,73 @@ include_examples "with pseudo-postcodes" - include_examples "with existing vaccination records", Programme.flu + include_examples "date deduplication with existing vaccination records", + Programme.flu + + context "when a similar vaccination record exists" do + let!(:other_vaccination_record) do + vaccine = Vaccine.find_by(nivs_name: "AstraZeneca Fluenz LAIV") + + create( + :vaccination_record, + :sourced_from_bulk_upload, + patient:, + programme:, + performed_at: Time.zone.local(2026, 1, 5, 0, 0, 0), + location:, + local_patient_id: "CIN-OXFORD-pat123456", + local_patient_id_uri: "https://cinnamon.nhs.uk/0de/system1", + performed_by_user: nil, + performed_by_given_name: vaccinator.given_name, + performed_by_family_name: vaccinator.family_name, + vaccine:, + batch: + create( + :batch, + team: nil, + name: "456", + expiry: Date.new(2026, 1, 6), + vaccine: + ) # different + ) + end + + it { should_not be_nil } + it { should_not eq other_vaccination_record } + its(:pending_changes) { should be_empty } + end + + context "when an identical vaccination record exists" do + let!(:other_vaccination_record) do + vaccine = Vaccine.find_by(nivs_name: "AstraZeneca Fluenz LAIV") + create( + :vaccination_record, + :sourced_from_bulk_upload, + patient:, + programme:, + performed_at: Time.zone.local(2026, 1, 5, 0, 0, 0), + location:, + local_patient_id: "CIN-OXFORD-pat123456", + local_patient_id_uri: "https://cinnamon.nhs.uk/0de/system1", + performed_by_user: nil, + performed_by_given_name: vaccinator.given_name, + performed_by_family_name: vaccinator.family_name, + vaccine:, + batch: + create( + :batch, + team: nil, + name: "123", + expiry: Date.new(2026, 1, 6), + vaccine: + ) # identical + ) + end + + it { should_not be_nil } + it { should eq other_vaccination_record } + its(:pending_changes) { should be_empty } + end end context "of type hpv" do @@ -2570,9 +2642,11 @@ let(:data) { valid_bulk_hpv_data } + let(:programme) { Programme.hpv } + it { should be_administered } - its(:programme) { should eq(Programme.hpv) } + its(:programme) { should eq programme } its(:source) { should eq("bulk_upload") } @@ -2649,7 +2723,8 @@ include_examples "with pseudo-postcodes" - include_examples "with existing vaccination records", Programme.hpv + include_examples "date deduplication with existing vaccination records", + Programme.hpv end end end diff --git a/spec/models/immunisation_import_spec.rb b/spec/models/immunisation_import_spec.rb index dcfd291c29..b19daac552 100644 --- a/spec/models/immunisation_import_spec.rb +++ b/spec/models/immunisation_import_spec.rb @@ -174,6 +174,21 @@ around { |example| travel_to(Date.new(2025, 8, 1)) { example.run } } + context "with an empty CSV file (no data rows)" do + let(:programmes) { [Programme.flu] } + let(:file) { "valid_flu.csv" } + + it "handles empty imports without raising NoMethodError" do + # rubocop:disable RSpec/SubjectStub + allow(immunisation_import).to receive(:process_row).and_return( + :ignored_record_count + ) + # rubocop:enable RSpec/SubjectStub + + expect { process! }.not_to raise_error + end + end + context "with valid flu rows" do let(:programmes) { [Programme.flu] } let(:file) { "valid_flu.csv" } diff --git a/spec/models/onboarding_spec.rb b/spec/models/onboarding_spec.rb index 38a0637a1d..4ce87c283f 100644 --- a/spec/models/onboarding_spec.rb +++ b/spec/models/onboarding_spec.rb @@ -31,6 +31,8 @@ expect(team.phone).to eq("07700 900815") expect(team.phone_instructions).to eq("option 1, followed by option 3") expect(team.careplus_venue_code).to eq("EXAMPLE") + expect(team.careplus_staff_code).to eq("ABCD") + expect(team.careplus_staff_type).to eq("PQ") expect(team.programmes).to contain_exactly(programme) expect(team.locations.generic_clinic.count).to eq(1) @@ -114,7 +116,6 @@ expect(onboarding.errors.messages).to eq( { - "team.careplus_venue_code": ["can't be blank"], "team.name": ["can't be blank"], "team.phone": ["can't be blank", "is invalid"], "team.privacy_notice_url": ["can't be blank"], diff --git a/spec/models/patient/triage_status_spec.rb b/spec/models/patient/triage_status_spec.rb index 146792805e..9899e9e538 100644 --- a/spec/models/patient/triage_status_spec.rb +++ b/spec/models/patient/triage_status_spec.rb @@ -165,10 +165,7 @@ it { should be(:not_required) } end - before do - create(:vaccination_record, patient:, programme:) - create(:patient_vaccination_status, :vaccinated, patient:, programme:) - 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 diff --git a/spec/models/patient/vaccination_status_spec.rb b/spec/models/patient/vaccination_status_spec.rb deleted file mode 100644 index f40dd9cc6f..0000000000 --- a/spec/models/patient/vaccination_status_spec.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -# == Schema Information -# -# Table name: patient_vaccination_statuses -# -# id :bigint not null, primary key -# academic_year :integer not null -# dose_sequence :integer -# latest_date :date -# latest_session_status :integer -# programme_type :enum not null -# status :integer default("not_eligible"), not null -# latest_location_id :bigint -# patient_id :bigint not null -# -# Indexes -# -# idx_on_academic_year_patient_id_9c400fc863 (academic_year,patient_id) -# idx_on_patient_id_programme_type_academic_year_962639d2ac (patient_id,programme_type,academic_year) UNIQUE -# index_patient_vaccination_statuses_on_latest_location_id (latest_location_id) -# index_patient_vaccination_statuses_on_status (status) -# -# Foreign Keys -# -# fk_rails_... (latest_location_id => locations.id) -# fk_rails_... (patient_id => patients.id) ON DELETE => cascade -# -describe Patient::VaccinationStatus do - subject(:patient_vaccination_status) do - build(:patient_vaccination_status, patient:, programme:) - end - - let(:patient) { create(:patient, programmes: [programme]) } - let(:programme) { Programme.sample } - - it { should belong_to(:patient) } - - it do - expect(patient_vaccination_status).to define_enum_for(:status).with_values( - %i[not_eligible eligible due vaccinated] - ) - end - - describe "#assign_status" do - subject(:assign_status) { patient_vaccination_status.assign_status } - - let(:vaccination_generator) do - instance_double(StatusGenerator::Vaccination) - end - - before do - allow(StatusGenerator::Vaccination).to receive(:new).and_return( - vaccination_generator - ) - allow(vaccination_generator).to receive_messages( - dose_sequence: 1, - latest_date: Date.new(2020, 1, 1), - latest_location_id: 999, - latest_session_status: "unwell", - status: "vaccinated" - ) - end - - it "calls the status generators" do - assign_status - - expect(patient_vaccination_status.dose_sequence).to eq(1) - expect(patient_vaccination_status.latest_date).to eq(Date.new(2020, 1, 1)) - expect(patient_vaccination_status.latest_location_id).to eq(999) - expect(patient_vaccination_status.latest_session_status).to eq("unwell") - expect(patient_vaccination_status.status).to eq("vaccinated") - end - end -end diff --git a/spec/models/patient_location_spec.rb b/spec/models/patient_location_spec.rb index 87f25f0d55..f9c70a36c7 100644 --- a/spec/models/patient_location_spec.rb +++ b/spec/models/patient_location_spec.rb @@ -37,23 +37,6 @@ it { should have_many(:vaccination_records) } end - describe "callbacks" do - it "creates a patient team" do - expect { patient_location }.to change(PatientTeam, :count).by(1) - - patient_team = PatientTeam.last - expect(patient_team.patient_id).to eq(patient_location.patient_id) - expect(patient_team.team_id).to eq(session.team_id) - expect(patient_team.sources).to eq(%w[patient_location]) - end - - it "deletes a patient team" do - patient_location - - expect { patient_location.destroy! }.to change(PatientTeam, :count).by(-1) - end - end - describe "scopes" do describe "#appear_in_programmes" do subject(:scope) do diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb index b9daeb0f21..5e203fbfe4 100644 --- a/spec/models/team_spec.rb +++ b/spec/models/team_spec.rb @@ -5,7 +5,9 @@ # Table name: teams # # id :bigint not null, primary key -# careplus_venue_code :string not null +# careplus_staff_code :string +# careplus_staff_type :string +# careplus_venue_code :string # days_before_consent_reminders :integer default(7), not null # days_before_consent_requests :integer default(21), not null # days_before_invitations :integer default(21), not null @@ -78,4 +80,22 @@ end end end + + describe "#careplus_enabled?" do + subject(:careplus_enabled?) { team.careplus_enabled? } + + context "when careplus_staff_code and careplus_staff_type are present" do + let(:team) { create(:team, :with_careplus_enabled) } + + it { should be(true) } + end + + context "when careplus_staff_code or careplus_staff_type are not present" do + let(:team) do + create(:team, careplus_staff_code: nil, careplus_staff_type: nil) + end + + it { should be(false) } + end + end end diff --git a/spec/policies/import/issue_policy_spec.rb b/spec/policies/import/issue_policy_spec.rb index f69cb6cae4..1403774531 100644 --- a/spec/policies/import/issue_policy_spec.rb +++ b/spec/policies/import/issue_policy_spec.rb @@ -11,7 +11,7 @@ permissions :index?, :create?, :edit?, :new?, :show?, :update? do it { should permit(poc_only_user, vaccination_record) } - it { should permit(upload_only_user, vaccination_record) } + it { should_not permit(upload_only_user, vaccination_record) } end permissions :destroy? do diff --git a/spec/policies/team_policy_spec.rb b/spec/policies/team_policy_spec.rb index c99d8a1648..0a8e2a2ab8 100644 --- a/spec/policies/team_policy_spec.rb +++ b/spec/policies/team_policy_spec.rb @@ -5,6 +5,7 @@ let(:poc_only_team) { create(:team, :poc_only) } let(:upload_only_team) { create(:team, :upload_only) } + let(:other_team) { create(:team) } let(:user) { create(:nurse, teams: [poc_only_team, upload_only_team]) } permissions :index?, :create?, :destroy?, :update? do @@ -12,9 +13,9 @@ it { should_not permit(user, upload_only_team) } end - permissions :show? do + permissions :show?, :clinics?, :contact_details?, :schools?, :sessions? do it { should permit(user, poc_only_team) } it { should_not permit(user, upload_only_team) } - it { should_not permit(user, create(:team)) } + it { should_not permit(user, other_team) } end end diff --git a/spec/requests/api/testing/onboard_spec.rb b/spec/requests/api/testing/onboard_spec.rb index 2c0c3cc16e..eed5603117 100644 --- a/spec/requests/api/testing/onboard_spec.rb +++ b/spec/requests/api/testing/onboard_spec.rb @@ -51,7 +51,6 @@ { "clinics" => ["can't be blank"], "organisation.ods_code" => ["can't be blank"], - "team.careplus_venue_code" => ["can't be blank"], "team.name" => ["can't be blank"], "team.phone" => ["can't be blank", "is invalid"], "team.privacy_notice_url" => ["can't be blank"], diff --git a/spec/validators/batch_name_validator_spec.rb b/spec/validators/batch_name_validator_spec.rb new file mode 100644 index 0000000000..12896a22b3 --- /dev/null +++ b/spec/validators/batch_name_validator_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +describe BatchNameValidator do + subject(:model) { Validatable.new(name:) } + + before do + stub_const("Validatable", Class.new).class_eval do + include ActiveModel::Model + attr_accessor :name + validates :name, batch_name: true + end + end + + shared_examples "can't be blank" do + it { should be_invalid } + + it "has the correct error message" do + expect(model.valid?).to be false + expect(model.errors[:name]).to include("can't be blank") + end + end + + shared_examples "is invalid" do + it { should be_invalid } + + it "has the correct error message" do + expect(model.valid?).to be false + expect(model.errors[:name]).to include("is invalid") + end + end + + context "with a nil value" do + let(:name) { nil } + + it_behaves_like "can't be blank" + end + + context "with an empty string" do + let(:name) { "" } + + it_behaves_like "can't be blank" + end + + context "with whitespace only" do + let(:name) { " " } + + it_behaves_like "can't be blank" + end + + context "with a single character" do + let(:name) { "A" } + + it { should be_invalid } + + it "has the correct error message" do + expect(model.valid?).to be false + expect(model.errors[:name]).to include( + "is too short (minimum is 2 characters)" + ) + end + end + + context "with 101 characters" do + let(:name) { "A" * 101 } + + it { should be_invalid } + + it "has the correct error message" do + expect(model.valid?).to be false + expect(model.errors[:name]).to include( + "is too long (maximum is 100 characters)" + ) + end + end + + context "with special characters" do + let(:name) { "Batch-123" } + + it_behaves_like "is invalid" + end + + context "with spaces" do + let(:name) { "Batch 123" } + + it_behaves_like "is invalid" + end + + context "with underscores" do + let(:name) { "Batch_123" } + + it_behaves_like "is invalid" + end + + context "with dots" do + let(:name) { "Batch.123" } + + it_behaves_like "is invalid" + end + + context "with symbols" do + let(:name) { "Batch@123" } + + it_behaves_like "is invalid" + end + + context "with 2 characters" do + let(:name) { "AB" } + + it { should be_valid } + end + + context "with 100 characters" do + let(:name) { "A" * 100 } + + it { should be_valid } + end + + context "with alphanumeric characters" do + let(:name) { "Batch123" } + + it { should be_valid } + end + + context "with only letters" do + let(:name) { "BatchName" } + + it { should be_valid } + end + + context "with only numbers" do + let(:name) { "123456" } + + it { should be_valid } + end + + context "with mixed case" do + let(:name) { "BaTcH123" } + + it { should be_valid } + end +end diff --git a/terraform/app/env/pentest-backend.hcl b/terraform/app/env/pentest-backend.hcl new file mode 100644 index 0000000000..3b4d6af708 --- /dev/null +++ b/terraform/app/env/pentest-backend.hcl @@ -0,0 +1,2 @@ +bucket = "nhse-mavis-terraform-state" +key = "terraform-pentest.tfstate" diff --git a/terraform/app/env/pentest.tfvars b/terraform/app/env/pentest.tfvars new file mode 100644 index 0000000000..08be8dca73 --- /dev/null +++ b/terraform/app/env/pentest.tfvars @@ -0,0 +1,24 @@ +environment = "pentest" +rails_master_key_path = "/copilot/mavis/secrets/STAGING_RAILS_MASTER_KEY" +mise_sops_age_key_path = "/copilot/mavis/secrets/STAGING_MISE_SOPS_AGE_KEY" +dns_certificate_arn = null +resource_name = { + rds_security_group = "mavis-pentest-rds-sg" + loadbalancer = "mavis-pentest-alb" + lb_security_group = "mavis-pentest-alb-sg" + cloudwatch_vpc_log_group = "mavis-pentest-FlowLogs" +} + +http_hosts = { + MAVIS__HOST = "pentest.mavistesting.com" + MAVIS__GIVE_OR_REFUSE_CONSENT_HOST = "pentest.mavistesting.com" +} + +max_aurora_capacity_units = 64 +container_insights = "enhanced" + +enable_enhanced_db_monitoring = true +enable_backup_to_vault = true + +minimum_reporting_replicas = 2 +maximum_reporting_replicas = 4 diff --git a/terraform/app/variables.tf b/terraform/app/variables.tf index 2372569f84..df0457a99f 100644 --- a/terraform/app/variables.tf +++ b/terraform/app/variables.tf @@ -11,7 +11,7 @@ variable "environment" { validation { condition = contains([ - "sandbox-alpha", "sandbox-beta", "qa", "performance", "test", "training", "preview", "production" + "sandbox-alpha", "sandbox-beta", "qa", "performance", "test", "training", "preview", "pentest", "production" ], var.environment) error_message = "Valid values for environment: sandbox-alpha, sandbox-beta, qa, performance, test, training, preview, production." } diff --git a/yarn.lock b/yarn.lock index 2501fd1ab4..d17d197658 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3355,9 +3355,9 @@ lodash.truncate@^4.4.2: integrity sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw== lodash@^4.7.0: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + version "4.17.23" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a" + integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== lru-cache@^10.2.0, lru-cache@^10.4.3: version "10.4.3" @@ -3819,10 +3819,10 @@ postcss@^8.5.6: picocolors "^1.1.1" source-map-js "^1.2.1" -prettier@^3.8.0: - version "3.8.0" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.0.tgz#f72cf71505133f40cfa2ef77a2668cdc558fcd69" - integrity sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA== +prettier@^3.8.1: + version "3.8.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" + integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== pretty-format@30.0.5, pretty-format@^30.0.0: version "30.0.5"