diff --git a/README.md b/README.md index 1e31901c5..5541e64b8 100644 --- a/README.md +++ b/README.md @@ -289,11 +289,9 @@ The `cis2` feature flag also needs to be enabled in Flipper for CIS2 logins to w ## Rake tasks -- `programmes:create[type]` - `schools:add_to_team[team_id,urn]` - `teams:create_hpv[email,name,phone,ods_code,privacy_policy_url,reply_to_id]` -- `vaccines:add_to_programme[programme_id,vaccine_nivs_name]` -- `vaccines:seed` +- `vaccines:seed[type]` See the [Rake tasks documentation](docs/rake-tasks.md) for more information. diff --git a/app/models/batch.rb b/app/models/batch.rb index 0ea89b2fd..ec04e135f 100644 --- a/app/models/batch.rb +++ b/app/models/batch.rb @@ -28,8 +28,6 @@ class Batch < ApplicationRecord has_and_belongs_to_many :immunisation_imports - has_many :programmes, through: :vaccine - validates :name, presence: true validates :expiry, presence: true end diff --git a/app/models/programme.rb b/app/models/programme.rb index 3b9391017..3710e0900 100644 --- a/app/models/programme.rb +++ b/app/models/programme.rb @@ -19,7 +19,6 @@ class Programme < ApplicationRecord audited has_and_belongs_to_many :sessions - has_and_belongs_to_many :vaccines has_many :consent_forms has_many :consents @@ -28,6 +27,7 @@ class Programme < ApplicationRecord has_many :team_programmes has_many :triages has_many :vaccination_records + has_many :vaccines has_many :batches, through: :vaccines has_many :patient_sessions, through: :sessions @@ -36,34 +36,13 @@ class Programme < ApplicationRecord enum :type, { flu: "flu", hpv: "hpv" }, validate: true - validate :vaccines_match_type - def name human_enum_name(:type) end - def vaccine_ids - @vaccine_ids ||= vaccines.map(&:id) - end - - def vaccine_ids=(ids) - self.vaccines = Vaccine.where(id: ids) - end - YEAR_GROUPS_BY_TYPE = { "flu" => (0..11).to_a, "hpv" => (8..11).to_a }.freeze def year_groups YEAR_GROUPS_BY_TYPE.fetch(type) end - - private - - def vaccines_match_type - errors.add(:vaccines, :blank) if vaccines.empty? - - vaccine_types = vaccines.map(&:type).uniq - unless vaccine_types.empty? || vaccine_types == [type] - errors.add(:vaccines, :match_type) - end - end end diff --git a/app/models/vaccine.rb b/app/models/vaccine.rb index 579cba982..a8e8a9809 100644 --- a/app/models/vaccine.rb +++ b/app/models/vaccine.rb @@ -17,20 +17,28 @@ # type :string not null # created_at :datetime not null # updated_at :datetime not null +# programme_id :bigint not null # # Indexes # # index_vaccines_on_gtin (gtin) UNIQUE # index_vaccines_on_manufacturer_and_brand (manufacturer,brand) UNIQUE # index_vaccines_on_nivs_name (nivs_name) UNIQUE +# index_vaccines_on_programme_id (programme_id) # index_vaccines_on_snomed_product_code (snomed_product_code) UNIQUE # index_vaccines_on_snomed_product_term (snomed_product_term) UNIQUE # +# Foreign Keys +# +# fk_rails_... (programme_id => programmes.id) +# class Vaccine < ApplicationRecord self.inheritance_column = nil audited + belongs_to :programme + has_and_belongs_to_many :programmes has_many :health_questions, dependent: :destroy has_many :batches, -> { order(:name) } diff --git a/app/policies/batch_policy.rb b/app/policies/batch_policy.rb index 4af73d1fe..51ce4c42f 100644 --- a/app/policies/batch_policy.rb +++ b/app/policies/batch_policy.rb @@ -8,7 +8,11 @@ def initialize(user, scope) end def resolve - @scope.joins(:programmes).where(programmes: @user.programmes) + @scope.joins(vaccine: :programme).where( + vaccine: { + programme: @user.programmes + } + ) end end end diff --git a/app/policies/vaccine_policy.rb b/app/policies/vaccine_policy.rb index 6b5d7716d..31eae1ee7 100644 --- a/app/policies/vaccine_policy.rb +++ b/app/policies/vaccine_policy.rb @@ -8,7 +8,7 @@ def initialize(user, scope) end def resolve - @scope.joins(:programmes).where(programmes: @user.programmes) + @scope.joins(:programme).where(programme: @user.programmes) end end end diff --git a/db/migrate/20240927134753_add_programme_to_vaccines.rb b/db/migrate/20240927134753_add_programme_to_vaccines.rb new file mode 100644 index 000000000..c1eec52b7 --- /dev/null +++ b/db/migrate/20240927134753_add_programme_to_vaccines.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddProgrammeToVaccines < ActiveRecord::Migration[7.2] + def up + add_reference :vaccines, :programme, foreign_key: true + + Vaccine.all.find_each do |vaccine| + vaccine.update!( + programme: Programme.find_or_create_by!(type: vaccine.type) + ) + end + + change_column_null :vaccines, :programme_id, false + end + + def down + remove_reference :vaccines, :programme + end +end diff --git a/db/schema.rb b/db/schema.rb index f4026e67b..b14d47a88 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[7.2].define(version: 2024_09_27_124718) do +ActiveRecord::Schema[7.2].define(version: 2024_09_27_134753) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -599,9 +599,11 @@ t.string "snomed_product_term", null: false t.text "nivs_name", null: false t.boolean "discontinued", default: false, null: false + t.bigint "programme_id", null: false t.index ["gtin"], name: "index_vaccines_on_gtin", unique: true t.index ["manufacturer", "brand"], name: "index_vaccines_on_manufacturer_and_brand", unique: true t.index ["nivs_name"], name: "index_vaccines_on_nivs_name", unique: true + t.index ["programme_id"], name: "index_vaccines_on_programme_id" t.index ["snomed_product_code"], name: "index_vaccines_on_snomed_product_code", unique: true t.index ["snomed_product_term"], name: "index_vaccines_on_snomed_product_term", unique: true end @@ -668,4 +670,5 @@ add_foreign_key "vaccination_records", "programmes" add_foreign_key "vaccination_records", "users", column: "performed_by_user_id" add_foreign_key "vaccination_records", "vaccines" + add_foreign_key "vaccines", "programmes" end diff --git a/docs/rake-tasks.md b/docs/rake-tasks.md index 0c080c3c8..f02a3477d 100644 --- a/docs/rake-tasks.md +++ b/docs/rake-tasks.md @@ -1,13 +1,5 @@ # Rake Tasks -## Programmes - -### `programmes:create[type]` - -- `type` - Either `flu` or `hpv`. - -This creates a new programme. - ## Schools ### `schools:add_to_team[team_id,urn]` @@ -34,14 +26,9 @@ This creates a new team with an HPV programme. ## Vaccines -### `vaccines:add_to_programme[programme_id, vaccine_nivs_name]` - -- `programme_id` - The ID of the programme. -- `vaccine_nivs_name` - The NIVS name of the vaccine. - -This adds a vaccine to a programme. +### `vaccines:seed[type]` -### `vaccines:seed` +- `type` - The type of vaccine, either `flu` or `hpv`. (optional) This creates the default set of vaccine records, or if they already exist, updates any existing vaccine records to match the default set. diff --git a/erd.pdf b/erd.pdf index 774e03f23..466e23d8e 100644 Binary files a/erd.pdf and b/erd.pdf differ diff --git a/lib/tasks/add_health_questions.rake b/lib/tasks/add_health_questions.rake index 5096f5b99..52e1fdac3 100644 --- a/lib/tasks/add_health_questions.rake +++ b/lib/tasks/add_health_questions.rake @@ -6,27 +6,23 @@ desc <<-DESC Add health questions to a vaccine. Usage: - rake add_health_questions[team_id,vaccine_id,replace] + rake add_health_questions[programme,vaccine_id,replace] - The vaccine must belong to the team given, this is a safety check. + The vaccine must belong to the programme given, this is a safety check. Use "replace" for the replace arg to replace the existing health questions. Example: - rake add_health_questions[1,1,replace] + rake add_health_questions[hpv,1,replace] DESC task :add_health_questions, - %i[team_id vaccine_id replace] => :environment do |_task, args| - team = Team.find(args[:team_id]) - vaccine = - team - .programmes - .flat_map(&:vaccines) - .find { |v| v.id == args[:vaccine_id].to_i } - raise "Vaccine not found for the given team" if vaccine.nil? + %i[programme vaccine_id replace] => :environment do |_task, args| + programme = Programme.find_by!(type: args[:programme]) + vaccine = programme.vaccines.find_by(id: args[:vaccine_id]) + raise "Vaccine not found for the given programme" if vaccine.nil? existing_health_questions = vaccine.health_questions.in_order - puts "Existing health questions for #{team.name}'s #{vaccine.type} vaccine #{vaccine.brand}" + puts "Existing health questions for #{programme.name}'s vaccine #{vaccine.brand}" if existing_health_questions.any? existing_health_questions.each do |health_question| puts Rainbow(" #{health_question.title}").yellow @@ -65,7 +61,7 @@ task :add_health_questions, next end - puts "\nThese will be the health questions for #{team.name}'s #{vaccine.type} vaccine #{vaccine.brand}:" + puts "\nThese will be the health questions for #{programme.name}'s #{vaccine.type} vaccine #{vaccine.brand}:" unless replace existing_health_questions.each do |health_question| puts Rainbow(" [old] #{health_question.title}").black diff --git a/lib/tasks/programmes.rake b/lib/tasks/programmes.rake deleted file mode 100644 index ebb1f6833..000000000 --- a/lib/tasks/programmes.rake +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -namespace :programmes do - desc "Create a new programme for a team." - task :create, %i[type] => :environment do |_, args| - team = Team.find_by(id: args[:team_id]) - type = args[:type] - - raise "Could not find team." if team.nil? - raise "Invalid type." unless %w[flu hpv].include?(type) - - if Programme.exists?(type:) - raise "A programme of this type already exists for this team." - end - - vaccines = Vaccine.active.where(type:).to_a - - raise "There are no vaccines for this type of programme." if vaccines.empty? - - programme = Programme.create!(type:, vaccines:) - - puts "New #{programme.name} programme with ID #{programme.id} created." - puts "Vaccines: #{vaccines.map(&:brand).join(", ")}" - end -end diff --git a/lib/tasks/teams.rake b/lib/tasks/teams.rake index 31408794a..b7cdb1add 100644 --- a/lib/tasks/teams.rake +++ b/lib/tasks/teams.rake @@ -15,8 +15,6 @@ namespace :teams do :environment do |_task, args| include TaskHelpers - Rake::Task["programmes:create"].invoke("hpv") - raise "Ensure vaccines exist before creating a team." unless Vaccine.exists? if args.to_a.empty? && $stdin.isatty && $stdout.isatty @@ -38,6 +36,8 @@ namespace :teams do end ActiveRecord::Base.transaction do + programme = Programme.find_or_create_by!(type: "hpv") + team = Team.create!( email:, @@ -48,7 +48,7 @@ namespace :teams do reply_to_id: ) - TeamProgramme.create!(team:, programme: Programme.find_by!(type: "hpv")) + TeamProgramme.create!(team:, programme:) puts "New #{team.name} team with ID #{team.id} created." end diff --git a/lib/tasks/vaccines.rake b/lib/tasks/vaccines.rake index 4cba4a22b..b19f8c507 100644 --- a/lib/tasks/vaccines.rake +++ b/lib/tasks/vaccines.rake @@ -2,10 +2,16 @@ namespace :vaccines do desc "Seed the vaccine table from the built-in vaccine data." - task seed: :environment do + task :seed, %i[type] => :environment do |_task, args| + type = args[:type] + all_data = YAML.load_file(Rails.root.join("config/vaccines.yml")) all_data.each_value do |data| + next if type.present? && data["type"] != type + + programme = Programme.find_or_create_by!(type: data["type"]) + vaccine = Vaccine.find_or_initialize_by( snomed_product_code: data["snomed_product_code"] @@ -19,6 +25,7 @@ namespace :vaccines do vaccine.nivs_name = data["nivs_name"] vaccine.snomed_product_term = data["snomed_product_term"] vaccine.type = data["type"] + vaccine.programme = programme vaccine.save! @@ -39,25 +46,4 @@ namespace :vaccines do ) end end - - desc "Add a vaccine to a programme." - task :add_to_programme, - %i[programme_id vaccine_nivs_name] => :environment do |_, args| - programme = Programme.find_by(id: args[:programme_id]) - vaccine = Vaccine.find_by(nivs_name: args[:vaccine_nivs_name]) - - if programme.nil? || vaccine.nil? - raise "Could not find programme or vaccine." - end - - if programme.vaccines.include?(vaccine) - raise "Vaccine is already part of the programme." - end - - if vaccine.type != programme.type - raise "Vaccine is not suitable for this programme type." - end - - programme.vaccines << vaccine - end end diff --git a/spec/components/app_vaccination_record_summary_component_spec.rb b/spec/components/app_vaccination_record_summary_component_spec.rb index 8067d6335..d1dcd6d15 100644 --- a/spec/components/app_vaccination_record_summary_component_spec.rb +++ b/spec/components/app_vaccination_record_summary_component_spec.rb @@ -7,10 +7,10 @@ let(:administered_at) { Time.zone.local(2024, 9, 6, 12) } let(:location) { create(:location, :school, name: "Hogwarts") } - let(:programme) { create(:programme, type: vaccine&.type || :hpv) } + let(:programme) { create(:programme, :hpv) } let(:session) { create(:session, programme:, location:) } let(:patient_session) { create(:patient_session, session:) } - let(:vaccine) { create(:vaccine, :gardasil_9) } + let(:vaccine) { programme.vaccines.first } let(:batch) do create(:batch, name: "ABC", expiry: Date.new(2020, 1, 1), vaccine:) end diff --git a/spec/factories/programmes.rb b/spec/factories/programmes.rb index 23cf5fdf7..4edc4b56c 100644 --- a/spec/factories/programmes.rb +++ b/spec/factories/programmes.rb @@ -18,20 +18,24 @@ transient { batch_count { 1 } } type { %w[flu hpv].sample } - vaccines { [association(:vaccine, type:, batch_count:)] } + vaccines do + [association(:vaccine, type:, batch_count:, programme: instance)] + end trait :hpv do type { "hpv" } - vaccines { [association(:vaccine, :gardasil_9, batch_count:)] } + vaccines do + [association(:vaccine, :gardasil_9, batch_count:, programme: instance)] + end end trait :hpv_all_vaccines do hpv vaccines do [ - association(:vaccine, :cervarix, batch_count:), - association(:vaccine, :gardasil, batch_count:), - association(:vaccine, :gardasil_9, batch_count:) + association(:vaccine, :cervarix, batch_count:, programme: instance), + association(:vaccine, :gardasil, batch_count:, programme: instance), + association(:vaccine, :gardasil_9, batch_count:, programme: instance) ] end end @@ -45,12 +49,37 @@ type { "flu" } vaccines do [ - association(:vaccine, :adjuvanted_quadrivalent, batch_count:), - association(:vaccine, :cell_quadrivalent, batch_count:), - association(:vaccine, :fluenz_tetra, batch_count:), - association(:vaccine, :quadrivalent_influenza, batch_count:), - association(:vaccine, :quadrivalent_influvac_tetra, batch_count:), - association(:vaccine, :supemtek, batch_count:) + association( + :vaccine, + :adjuvanted_quadrivalent, + batch_count:, + programme: instance + ), + association( + :vaccine, + :cell_quadrivalent, + batch_count:, + programme: instance + ), + association( + :vaccine, + :fluenz_tetra, + batch_count:, + programme: instance + ), + association( + :vaccine, + :quadrivalent_influenza, + batch_count:, + programme: instance + ), + association( + :vaccine, + :quadrivalent_influvac_tetra, + batch_count:, + programme: instance + ), + association(:vaccine, :supemtek, batch_count:, programme: instance) ] end end @@ -59,21 +88,65 @@ flu vaccines do [ - association(:vaccine, :adjuvanted_quadrivalent, batch_count:), - association(:vaccine, :cell_quadrivalent, batch_count:), - association(:vaccine, :fluad_tetra, batch_count:), - association(:vaccine, :flucelvax_tetra, batch_count:), - association(:vaccine, :fluenz_tetra, batch_count:), - association(:vaccine, :quadrivalent_influenza, batch_count:), - association(:vaccine, :quadrivalent_influvac_tetra, batch_count:), - association(:vaccine, :supemtek, batch_count:) + association( + :vaccine, + :adjuvanted_quadrivalent, + batch_count:, + programme: instance + ), + association( + :vaccine, + :cell_quadrivalent, + batch_count:, + programme: instance + ), + association( + :vaccine, + :fluad_tetra, + batch_count:, + programme: instance + ), + association( + :vaccine, + :flucelvax_tetra, + batch_count:, + programme: instance + ), + association( + :vaccine, + :fluenz_tetra, + batch_count:, + programme: instance + ), + association( + :vaccine, + :quadrivalent_influenza, + batch_count:, + programme: instance + ), + association( + :vaccine, + :quadrivalent_influvac_tetra, + batch_count:, + programme: instance + ), + association(:vaccine, :supemtek, batch_count:, programme: instance) ] end end trait :flu_nasal_only do flu - vaccines { [association(:vaccine, :fluenz_tetra, batch_count:)] } + vaccines do + [ + association( + :vaccine, + :fluenz_tetra, + batch_count:, + programme: instance + ) + ] + end end end end diff --git a/spec/factories/vaccines.rb b/spec/factories/vaccines.rb index 0a2952432..c93da192b 100644 --- a/spec/factories/vaccines.rb +++ b/spec/factories/vaccines.rb @@ -17,20 +17,28 @@ # type :string not null # created_at :datetime not null # updated_at :datetime not null +# programme_id :bigint not null # # Indexes # # index_vaccines_on_gtin (gtin) UNIQUE # index_vaccines_on_manufacturer_and_brand (manufacturer,brand) UNIQUE # index_vaccines_on_nivs_name (nivs_name) UNIQUE +# index_vaccines_on_programme_id (programme_id) # index_vaccines_on_snomed_product_code (snomed_product_code) UNIQUE # index_vaccines_on_snomed_product_term (snomed_product_term) UNIQUE # +# Foreign Keys +# +# fk_rails_... (programme_id => programmes.id) +# FactoryBot.define do factory :vaccine do transient { batch_count { 1 } } type { %w[flu hpv].sample } + programme { Programme.find_or_create_by!(type:) } + brand { Faker::Commerce.product_name } manufacturer { Faker::Company.name } sequence(:nivs_name) { |n| "#{brand.parameterize}-#{n}" } diff --git a/spec/features/manage_vaccines_spec.rb b/spec/features/manage_vaccines_spec.rb index 2a16cee4b..e58052262 100644 --- a/spec/features/manage_vaccines_spec.rb +++ b/spec/features/manage_vaccines_spec.rb @@ -14,9 +14,8 @@ end def given_my_team_is_running_an_hpv_vaccination_programme - @programme = create(:programme, :hpv_no_batches) - @team = create(:team, :with_one_nurse, programmes: [@programme]) - @vaccine = @programme.vaccines.first + programme = create(:programme, :hpv_no_batches) + @team = create(:team, :with_one_nurse, programmes: [programme]) end def when_i_manage_vaccines diff --git a/spec/features/user_authorisation_spec.rb b/spec/features/user_authorisation_spec.rb index cf955d024..87c160bb4 100644 --- a/spec/features/user_authorisation_spec.rb +++ b/spec/features/user_authorisation_spec.rb @@ -18,8 +18,7 @@ end def given_an_hpv_programme_is_underway_with_two_teams - vaccine = create(:vaccine, :hpv) - programme = create(:programme, :hpv, vaccines: [vaccine]) + programme = create(:programme, :hpv) @team = create(:team, :with_one_nurse, programmes: [programme]) @other_team = create(:team, :with_one_nurse, programmes: [programme]) diff --git a/spec/models/dps_export_row_spec.rb b/spec/models/dps_export_row_spec.rb index b37841907..9f3924a52 100644 --- a/spec/models/dps_export_row_spec.rb +++ b/spec/models/dps_export_row_spec.rb @@ -3,11 +3,9 @@ describe DPSExportRow do subject(:row) { described_class.new(vaccination_record) } - let(:programme) do - create(:programme, type: vaccine.type, vaccines: [vaccine]) - end + let(:programme) { create(:programme, type: "hpv") } let(:team) { create(:team, programmes: [programme]) } - let(:vaccine) { create(:vaccine, :gardasil_9, dose: 0.5) } + let(:vaccine) { create(:vaccine, :gardasil_9, programme:, dose: 0.5) } let(:location) { create(:location, :school) } let(:school) { create(:location, :school) } let(:patient) do @@ -188,11 +186,12 @@ end context "when the vaccine is a nasal spray" do - let(:vaccine) { create :vaccine, :fluenz_tetra } + let(:vaccine) { create(:vaccine, :fluenz_tetra, programme:) } let(:vaccination_record) do create( :vaccination_record, + programme:, vaccine:, batch: create(:batch, vaccine:), delivery_site: :nose, @@ -210,7 +209,7 @@ end context "when the vaccine is an intramuscular injection" do - let(:vaccine) { create :vaccine, :quadrivalent_influenza } + let(:vaccine) { create(:vaccine, :quadrivalent_influenza, programme:) } it "has route_of_vaccination_code" do expect(array[26]).to eq "78421000" diff --git a/spec/models/programme_spec.rb b/spec/models/programme_spec.rb index 0cc8979cb..4edb28481 100644 --- a/spec/models/programme_spec.rb +++ b/spec/models/programme_spec.rb @@ -20,19 +20,6 @@ describe "validations" do it { should validate_presence_of(:type) } it { should validate_inclusion_of(:type).in_array(%w[flu hpv]) } - - context "when vaccines don't match type" do - subject(:programme) do - build(:programme, type: "flu", vaccines: [build(:vaccine, type: "hpv")]) - end - - it "is invalid" do - expect(programme).to be_invalid - expect(programme.errors[:vaccines]).to include( - /must be suitable for the programme type/ - ) - end - end end describe "#name" do diff --git a/spec/models/vaccine_spec.rb b/spec/models/vaccine_spec.rb index ff5e680b2..142505923 100644 --- a/spec/models/vaccine_spec.rb +++ b/spec/models/vaccine_spec.rb @@ -17,15 +17,21 @@ # type :string not null # created_at :datetime not null # updated_at :datetime not null +# programme_id :bigint not null # # Indexes # # index_vaccines_on_gtin (gtin) UNIQUE # index_vaccines_on_manufacturer_and_brand (manufacturer,brand) UNIQUE # index_vaccines_on_nivs_name (nivs_name) UNIQUE +# index_vaccines_on_programme_id (programme_id) # index_vaccines_on_snomed_product_code (snomed_product_code) UNIQUE # index_vaccines_on_snomed_product_term (snomed_product_term) UNIQUE # +# Foreign Keys +# +# fk_rails_... (programme_id => programmes.id) +# describe Vaccine, type: :model do describe "validation" do diff --git a/spec/policies/session_policy_spec.rb b/spec/policies/session_policy_spec.rb index 4b2a9c7c4..0b5ea8a18 100644 --- a/spec/policies/session_policy_spec.rb +++ b/spec/policies/session_policy_spec.rb @@ -9,7 +9,7 @@ let(:user) { create(:user, teams: [team]) } let(:users_teams_session) { create(:session, team:, programme:) } - let(:another_teams_session) { create(:session) } + let(:another_teams_session) { create(:session, programme:) } it { should include(users_teams_session) } it { should_not include(another_teams_session) }