diff --git a/.github/dependencies.txt b/.github/dependencies.txt index d6e2b1ac7be..3ab3a8ecef5 100644 --- a/.github/dependencies.txt +++ b/.github/dependencies.txt @@ -32,6 +32,7 @@ libxext libxml2-dev libxrender libxslt-dev +linux-headers nodejs npm nss @@ -48,4 +49,5 @@ ttf-droid ttf-freefont ttf-liberation tzdata +yaml-dev yarn diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 075f8283d10..070775da113 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -82,11 +82,11 @@ def update User.transaction do @user.skip_reconfirmation! # Associations don't play well with acts_as_paranoid, so manually clean up user ACLs - @user.user_group_members.where.not(user_group_id: assigned_user_group_ids).destroy_all + @user.user_group_members.where.not(user_group_id: assigned_user_group_ids).destroy_all unless changing_to_acls? # TODO: START_ACL remove when ACL transition complete # Associations don't play well with acts_as_paranoid, so manually clean up user roles - if ! @user.using_acls? + if ! user_using_or_changing_to_acls? @user.user_roles.where.not(role_id: user_params[:legacy_role_ids]&.select(&:present?)).destroy_all @user.access_groups.not_system. where.not(id: user_params[:access_group_ids]&.select(&:present?)).each do |g| @@ -98,12 +98,21 @@ def update end # END_ACL @user.disable_2fa! if user_params[:otp_required_for_login] == 'false' - @user.update!(user_params) + + # The User Group data is not captured for update when using the Role-Based view. This means it will not be included + # in the params when switching from Role-Based permissions to ACLs. In order to prevent wiping out any existing + # user_group_id data, we need to ignore this param when changing to an ACL based permissions. + # The reverse is true for the access_group_ids field. + params_to_update = user_params + params_to_update = params_to_update.except(:user_group_ids) if changing_to_acls? + params_to_update = params_to_update.except(:access_group_ids) if changing_to_role_based? + @user.update!(params_to_update) + # if we have a user to copy user groups from, add them - copy_user_groups if @user.using_acls? + copy_user_groups if user_using_or_changing_to_acls? # TODO: START_ACL remove when ACL transition complete # Restore any health roles we previously had - if ! @user.using_acls? + if ! user_using_or_changing_to_acls? @user.legacy_roles = (@user.legacy_roles + existing_health_roles).uniq @user.set_viewables viewable_params end @@ -148,6 +157,18 @@ def title_for_index 'User List' end + private def changing_to_acls? + params[:user][:permission_context] == 'acls' && @user.permission_context != params[:user][:permission_context] + end + + private def changing_to_role_based? + params[:user][:permission_context] == 'role_based' && @user.permission_context != params[:user][:permission_context] + end + + private def user_using_or_changing_to_acls? + @user.using_acls? || changing_to_acls? + end + private def adding_admin? @adding_admin ||= begin adding_admin = false diff --git a/app/jobs/importing/hud_zip/fetch_and_import_job.rb b/app/jobs/importing/hud_zip/fetch_and_import_job.rb index bbad1a3136d..a0f0bde9a53 100644 --- a/app/jobs/importing/hud_zip/fetch_and_import_job.rb +++ b/app/jobs/importing/hud_zip/fetch_and_import_job.rb @@ -18,10 +18,11 @@ def perform(klass:, options:) raise "Unknown import class: #{klass}; You must add it to the list of known classes in FetchAndImportJob" unless safe_klass.present? data_source_id = options[:data_source_id] - lock_obtained = GrdaWarehouse::DataSource.with_advisory_lock(advisory_lock_name(data_source_id), timeout_seconds: 60) do + lock_obtained = nil + GrdaWarehouse::DataSource.with_advisory_lock(advisory_lock_name(data_source_id), timeout_seconds: 60) do safe_klass.constantize.new(**options).import! # To prevent re-running when called against the same files if run more than once in a day, yield true - true + lock_obtained = true end requeue_at(Time.current + WAIT_MINUTES.minutes, "Import of Data Source: #{data_source_id} already running...re-queuing job for #{WAIT_MINUTES} minutes from now") unless lock_obtained diff --git a/app/jobs/importing/hud_zip/hmis_auto_migrate_job.rb b/app/jobs/importing/hud_zip/hmis_auto_migrate_job.rb index 12fbbf3166b..15373cfae36 100644 --- a/app/jobs/importing/hud_zip/hmis_auto_migrate_job.rb +++ b/app/jobs/importing/hud_zip/hmis_auto_migrate_job.rb @@ -14,7 +14,8 @@ class HmisAutoMigrateJob < BaseJob after_enqueue :enforce_max_attempts def perform(upload_id:, data_source_id:, deidentified: false, allowed_projects: false) - lock_obtained = GrdaWarehouse::DataSource.with_advisory_lock(advisory_lock_name(data_source_id), timeout_seconds: 60) do + lock_obtained = nil + GrdaWarehouse::DataSource.with_advisory_lock(advisory_lock_name(data_source_id), timeout_seconds: 60) do importer = Importers::HmisAutoMigrate::UploadedZip.new( data_source_id: data_source_id, upload_id: upload_id, @@ -22,6 +23,7 @@ def perform(upload_id:, data_source_id:, deidentified: false, allowed_projects: allowed_projects: allowed_projects, ) importer.import! + lock_obtained = true end # when this exits, it will remove the current job from the queue, so add a new one to replace it diff --git a/app/jobs/update_warehouse_clients_caches_job.rb b/app/jobs/update_warehouse_clients_caches_job.rb index 8b81e0cf499..56038f30e42 100644 --- a/app/jobs/update_warehouse_clients_caches_job.rb +++ b/app/jobs/update_warehouse_clients_caches_job.rb @@ -10,8 +10,10 @@ class UpdateWarehouseClientsCachesJob < BaseJob def perform(client_ids: [], include_cas_and_cohorts: false, skip_expensive_calculations: false) # If any for this class are already running, requeue for a few minutes in the future - lock_obtained = GrdaWarehouse::WarehouseClientsProcessed.with_advisory_lock('UpdateWarehouseClientsCachesJob', timeout_seconds: 20) do + lock_obtained = nil + GrdaWarehouse::WarehouseClientsProcessed.with_advisory_lock('UpdateWarehouseClientsCachesJob', timeout_seconds: 20) do GrdaWarehouse::WarehouseClientsProcessed.update_cached_counts(client_ids: client_ids, include_cas_and_cohorts: include_cas_and_cohorts, skip_expensive_calculations: skip_expensive_calculations) + lock_obtained = true end requeue_at(Time.current + WAIT_MINUTES.minutes, "UpdateWarehouseClientsCachesJob is already running...re-queuing job for #{WAIT_MINUTES} minutes from now") unless lock_obtained diff --git a/app/models/grda_warehouse/tasks/scrub_pii/scrub_all_pii_task.rb b/app/models/grda_warehouse/tasks/scrub_pii/scrub_all_pii_task.rb index 4d1ca73ec59..b01f0815d3e 100644 --- a/app/models/grda_warehouse/tasks/scrub_pii/scrub_all_pii_task.rb +++ b/app/models/grda_warehouse/tasks/scrub_pii/scrub_all_pii_task.rb @@ -57,6 +57,7 @@ def models IncomeBenefitsReport::Client, MaYyaReport::Client, HudSpmReport::Fy2023::SpmEnrollment, + HudSpmReport::Fy2024::SpmEnrollment, GrdaWarehouse::AdHocClient, CePerformance::Client, GrdaWarehouse::ClientContact, diff --git a/app/models/grda_warehouse/utility.rb b/app/models/grda_warehouse/utility.rb index 9446a48eea3..76eebe7c6c0 100644 --- a/app/models/grda_warehouse/utility.rb +++ b/app/models/grda_warehouse/utility.rb @@ -107,11 +107,11 @@ def self.clear! tables << HudPathReport::Fy2020::PathClient if RailsDrivers.loaded.include?(:hud_path_report) if RailsDrivers.loaded.include?(:hud_spm_report) tables << HudSpmReport::Fy2020::SpmClient - tables << HudSpmReport::Fy2023::SpmEnrollment - tables << HudSpmReport::Fy2023::Episode - tables << HudSpmReport::Fy2023::BedNight - tables << HudSpmReport::Fy2023::EnrollmentLink - tables << HudSpmReport::Fy2023::Return + tables << HudSpmReport::Fy2024::SpmEnrollment + tables << HudSpmReport::Fy2024::Episode + tables << HudSpmReport::Fy2024::BedNight + tables << HudSpmReport::Fy2024::EnrollmentLink + tables << HudSpmReport::Fy2024::Return end if RailsDrivers.loaded.include?(:hud_data_quality_report) diff --git a/app/models/health/qualifying_activity.rb b/app/models/health/qualifying_activity.rb index 297faf3aa50..7b2d1918686 100644 --- a/app/models/health/qualifying_activity.rb +++ b/app/models/health/qualifying_activity.rb @@ -209,7 +209,7 @@ def self.date_search(start_date, end_date) end def face_to_face? - mode_of_contact.to_sym.in?(face_to_face_modes) + mode_of_contact&.to_sym.in?(face_to_face_modes) end # Return the string and the key so we can check either diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile index ef79c765d15..ca7f68cc51f 100644 --- a/docker/app/Dockerfile +++ b/docker/app/Dockerfile @@ -69,6 +69,7 @@ RUN apk update \ tzdata \ git \ bash \ + linux-headers \ freetds-dev \ icu icu-dev \ curl libcurl curl-dev \ diff --git a/drivers/all_neighbors_system_dashboard/app/models/all_neighbors_system_dashboard/header.rb b/drivers/all_neighbors_system_dashboard/app/models/all_neighbors_system_dashboard/header.rb index 27dd303ae5f..3cd734c4af6 100644 --- a/drivers/all_neighbors_system_dashboard/app/models/all_neighbors_system_dashboard/header.rb +++ b/drivers/all_neighbors_system_dashboard/app/models/all_neighbors_system_dashboard/header.rb @@ -29,13 +29,13 @@ def header_data name: 'Housing Placements', display_method: :number_with_delimiter, }, - { - id: 'days_to_obtain_housing', - icon: 'icon-house', - value: average_days_to_obtain_housing.round.abs, - name: 'Average Number of Days Between Referral and Housing Move-in', - display_method: :number_with_delimiter, - }, + # { + # id: 'days_to_obtain_housing', + # icon: 'icon-house', + # value: average_days_to_obtain_housing.round.abs, + # name: 'Average Number of Days Between Referral and Housing Move-in', + # display_method: :number_with_delimiter, + # }, { id: 'no_return', icon: 'icon-clip-board-check', diff --git a/drivers/health_pctp/app/models/health_pctp/careplan.rb b/drivers/health_pctp/app/models/health_pctp/careplan.rb index 8419966bef8..a391a846792 100644 --- a/drivers/health_pctp/app/models/health_pctp/careplan.rb +++ b/drivers/health_pctp/app/models/health_pctp/careplan.rb @@ -75,6 +75,8 @@ def editable? sent_to_pcp_on.nil? end + # NOTE: this needs to be updated before expires_on can be based on sent_to_pcp_on + # What are the consequences of changing the completed? calculation? def completed? patient_signed_on.present? end @@ -127,7 +129,7 @@ def current_goals_list end def expires_on - sent_to_pcp_on + 1.year + (sent_to_pcp_on.presence || patient_signed_on) + 1.year end def identifying_information diff --git a/drivers/hmis/app/models/hmis/form/definition.rb b/drivers/hmis/app/models/hmis/form/definition.rb index 095ab6e61c7..f94ae73468c 100644 --- a/drivers/hmis/app/models/hmis/form/definition.rb +++ b/drivers/hmis/app/models/hmis/form/definition.rb @@ -475,6 +475,11 @@ def validate_form_values(form_values) is_missing = value.nil? || (value.respond_to?(:empty?) && value.empty?) is_data_not_collected = value == 'DATA_NOT_COLLECTED' field_name = item.mapping&.field_name || item.mapping&.custom_field_key + + numeric_validator.call(item, value)&.each do |error_message| + errors.add field_name || :base, message: error_message, **error_context + end + # Validate required status if item.required && is_missing errors.add field_name || :base, :required, **error_context @@ -662,6 +667,10 @@ def self.infer_cded_field_type(item_type) end end + def numeric_validator + @numeric_validator ||= Hmis::Form::NumericInputValidator.new + end + # Helper for determining CustomDataElementDefinition attributes def self.generate_cded_field_label(item) label = item.readonly_text.presence || item.brief_text.presence || item.text.presence || item.link_id.humanize diff --git a/drivers/hmis/app/models/hmis/form/numeric_input_validator.rb b/drivers/hmis/app/models/hmis/form/numeric_input_validator.rb new file mode 100644 index 00000000000..f8aaaa45509 --- /dev/null +++ b/drivers/hmis/app/models/hmis/form/numeric_input_validator.rb @@ -0,0 +1,53 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +class Hmis::Form::NumericInputValidator + CURRENCY_RGX = /\A-?(?:[1-9]\d*|0)(?:\.\d{1,2})?\z/ + INTEGER_RGX = /\A-?(?:[1-9]\d*|0)\z/ + + SPECIAL_VALUES = ['DATA_NOT_COLLECTED', '_HIDDEN'].to_set.freeze + SUPPORTED_TYPES = ['INTEGER', 'CURRENCY'].to_set.freeze + + def call(item, value) + return [] if value.blank? || SPECIAL_VALUES.include?(value) + return [] unless item.type.in? SUPPORTED_TYPES + + format_errors = validate_format(item, value.to_s.strip) + return format_errors if format_errors.any? + + validate_bounds(item, value.to_d) + end + + private + + def validate_format(item, value) + case item.type + when 'INTEGER' + INTEGER_RGX.match?(value) ? [] : ['not a valid integer'] + when 'CURRENCY' + CURRENCY_RGX.match?(value) ? [] : ['not a valid currency amount'] + else + [] + end + end + + def validate_bounds(item, value) + return [] if item.bounds.blank? + + item.bounds.each_with_object([]) do |bound, errors| + next unless bound.severity == 'error' + # bound.value_number can be nil in the case where the bound is against a local constant or another question + next unless bound.value_number + + case bound.type + when 'MAX' + errors << "must be less than or equal to #{bound.value_number}" if value > bound.value_number + when 'MIN' + errors << "must be greater than or equal to #{bound.value_number}" if value < bound.value_number + end + end + end +end diff --git a/drivers/hmis/app/models/hmis/hoh_change_handler.rb b/drivers/hmis/app/models/hmis/hoh_change_handler.rb index 4fbe3681e9e..78951fe3852 100644 --- a/drivers/hmis/app/models/hmis/hoh_change_handler.rb +++ b/drivers/hmis/app/models/hmis/hoh_change_handler.rb @@ -73,9 +73,9 @@ def apply_changes! # TODO(#6857) For now, we leave the old MID as-is IF we were unable to transfer it because the new HoH entered after move-in. We plan to adjust this pending guidance from HUD. hhm.move_in_date = nil unless hhm.head_of_household? && hhm.move_in_date && !new_hoh_move_in_date - # Clear RelationshipToHoH on previous HoH + # Update RelationshipToHoH on previous HoH if hhm.head_of_household? - hhm.relationship_to_ho_h = 99 + hhm.relationship_to_ho_h = infer_relationship_to_new_hoh(new_hoh_enrollment) # Move-in Address(es) from old HoH should transfer to new HoH. We only expect 1, but it's OK if there are more. hhm.move_in_addresses.each { |addr| addr.update!(enrollment: new_hoh_enrollment) } end @@ -128,5 +128,21 @@ def self.move_in_date_not_transfered_msg(move_in_date) def add_warning(full_message) validation_errors.add(:enrollment, :informational, severity: :warning, full_message: full_message) end + + # infer which relationship the previous HoH should have to the new HoH + def infer_relationship_to_new_hoh(new_hoh) + case new_hoh.relationship_to_ho_h + when 3 # Spouse + 3 # Spouse + when 4 # Other relative + 4 # Other relative + when 5 # Unrelated household member + 5 # Unrelated household member + when 2 # Child + 4 # "Other relative" to indicate parent. This is unlikely, because child shouldn't become HoH in household with parent, but it's possible. + else + 5 # Unrelated household member + end + end end end diff --git a/drivers/hmis/app/models/hmis/hud/processors/income_benefit_processor.rb b/drivers/hmis/app/models/hmis/hud/processors/income_benefit_processor.rb index eae811153a1..d706d9e96e9 100644 --- a/drivers/hmis/app/models/hmis/hud/processors/income_benefit_processor.rb +++ b/drivers/hmis/app/models/hmis/hud/processors/income_benefit_processor.rb @@ -60,7 +60,14 @@ def income_source_attributes(amount_field, value) case @hud_values['IncomeBenefit.incomeFromAnySource'] when 'YES' # If overall income was 1 (yes), then this specific income field must be 1 or 0 (yes or no) - bool_attribute_value = amount_attribute_value&.positive? ? 1 : 0 + case amount_attribute_value + when Integer, Float, nil + bool_attribute_value = amount_attribute_value&.positive? ? 1 : 0 + else + # The frontend input is not expected to send numeric values here + Sentry.capture_message("Unexpected value \"#{amount_attribute_value}\" received for #{amount_attribute_name}") + bool_attribute_value = 0 + end when 'NO' # If overall income was 0 (no), then this specific income field is 0 (no) amount_attribute_value = nil diff --git a/drivers/hmis/lib/hmis_data_cleanup/util.rb b/drivers/hmis/lib/hmis_data_cleanup/util.rb index 50e967174b6..8e2abdfb57f 100644 --- a/drivers/hmis/lib/hmis_data_cleanup/util.rb +++ b/drivers/hmis/lib/hmis_data_cleanup/util.rb @@ -88,6 +88,17 @@ def self.make_sole_member_hoh! end end + # Change any RelationshipToHoH values that are 99 (Data not collected) to 5 (Unrelated household member) + # 99 is not a valid option for RelationshipToHoH, and causes flags in the LSA. Reference issue #7127. + def self.fix_relationship_to_hoh_99s! + without_papertrail_or_timestamps do + rows_affected = Hmis::Hud::Enrollment.hmis.where(relationship_to_hoh: 99). + update_all(relationship_to_hoh: 5) # skips callbacks + + Rails.logger.info "Set RelationshipToHoH 99=>5 on #{rows_affected} Enrollments" + end + end + # Fix any instances of enrollment-related records where the PersonalID does not match the Enrollment's PersonalIDs def self.fix_incorrect_personal_id_references!(classes: nil, dry_run: false) classes&.each do |klass| diff --git a/drivers/hmis/spec/models/hmis/form/numeric_input_validator_spec.rb b/drivers/hmis/spec/models/hmis/form/numeric_input_validator_spec.rb new file mode 100644 index 00000000000..c83764d84dd --- /dev/null +++ b/drivers/hmis/spec/models/hmis/form/numeric_input_validator_spec.rb @@ -0,0 +1,101 @@ +require 'rails_helper' + +RSpec.describe Hmis::Form::NumericInputValidator do + let(:validator) { described_class.new } + + def config_to_item(config) + Oj.load(config.to_json, mode: :compat, object_class: OpenStruct) + end + + shared_examples 'validates special values' do + it 'accepts blank values' do + expect(validator.call(item, '')).to be_empty + expect(validator.call(item, nil)).to be_empty + end + + it 'accepts special system values' do + expect(validator.call(item, 'DATA_NOT_COLLECTED')).to be_empty + expect(validator.call(item, '_HIDDEN')).to be_empty + end + end + + describe 'currency validation' do + let(:item) { config_to_item({ type: 'CURRENCY', bounds: [] }) } + + include_examples 'validates special values' + + it 'accepts valid currency formats' do + valid_currencies = ['0', '0.0', '0.00', '1', '1.0', '1.00', '-1', '-1.0', '-1.00', '1234.56'] + valid_currencies.each do |value| + expect(validator.call(item, value)).to be_empty, "Expected #{value} to be valid" + end + end + + it 'rejects invalid currency formats' do + invalid_currencies = ['abc', '1.234', '01', '00', '1a', 'a1', '.1', '1.', '001'] + invalid_currencies.each do |value| + expect(validator.call(item, value)).to eq(['not a valid currency amount']), "Expected #{value} to be invalid" + end + end + + context 'with bounds' do + let(:item) do + config_to_item( + { + type: 'CURRENCY', + bounds: [ + { type: 'MIN', value_number: 0, severity: 'error' }, + { type: 'MAX', value_number: 100, severity: 'error' }, + ], + }, + ) + end + + it 'validates within bounds' do + expect(validator.call(item, '50')).to be_empty + end + + it 'rejects values outside bounds' do + expect(validator.call(item, '-1')).to include('must be greater than or equal to 0') + expect(validator.call(item, '101')).to include('must be less than or equal to 100') + end + end + + context 'with null bounds' do + let(:item) do + config_to_item( + { + type: 'CURRENCY', + bounds: [ + { type: 'MAX', value_number: nil, severity: 'error' }, + ], + }, + ) + end + + it 'passes validation' do + expect(validator.call(item, '50')).to be_empty + end + end + end + + describe 'integer validation' do + let(:item) { config_to_item({ type: 'INTEGER', bounds: [] }) } + + include_examples 'validates special values' + + it 'accepts valid integer formats' do + valid_integers = ['0', '1', '-1', '1234', '-1234'] + valid_integers.each do |value| + expect(validator.call(item, value)).to be_empty, "Expected #{value} to be valid" + end + end + + it 'rejects invalid integer formats' do + invalid_integers = ['1.0', 'abc', '01', '00', '1a', 'a1', '.1', '1.', '001'] + invalid_integers.each do |value| + expect(validator.call(item, value)).to eq(['not a valid integer']), "Expected #{value} to be invalid" + end + end + end +end diff --git a/drivers/hmis/spec/models/hmis/form/validate_form_values_spec.rb b/drivers/hmis/spec/models/hmis/form/validate_form_values_spec.rb index 6b2161266c1..24c29a38b26 100644 --- a/drivers/hmis/spec/models/hmis/form/validate_form_values_spec.rb +++ b/drivers/hmis/spec/models/hmis/form/validate_form_values_spec.rb @@ -32,7 +32,7 @@ [[], true], ['DATA_NOT_COLLECTED', false], # DNC is a valid answer for a required field [0, false], - ['value', false], + ['0', false], [Hmis::Hud::Processors::Base::HIDDEN_FIELD_VALUE, false], # hidden field should not generate an error ].each do |value, should_error| it "should #{should_error ? '' : 'not'} error on #{value.nil? ? 'nil' : value}" do @@ -40,12 +40,11 @@ **completed_values, linkid_required: value, }.stringify_keys) - if should_error expected_error = { type: :required, severity: :error, link_id: 'linkid_required', readable_attribute: 'The Required Field' } expect(errors.map(&:to_h)).to contain_exactly(a_hash_including(expected_error)) else - expect(errors).to be_empty, errors.map(&:to_h) + expect(errors).to be_empty, -> { errors.map(&:to_h) } end end end @@ -72,7 +71,7 @@ expected_error = { type: :data_not_collected, severity: :warning, link_id: 'linkid_choice', readable_attribute: 'Choice field' } expect(errors.map(&:to_h)).to contain_exactly(a_hash_including(expected_error)) else - expect(errors).to be_empty, errors.map(&:to_h) + expect(errors).to be_empty, -> { errors.map(&:to_h) } end end end diff --git a/drivers/hmis/spec/requests/hmis/assessments/submit_household_assessments_spec.rb b/drivers/hmis/spec/requests/hmis/assessments/submit_household_assessments_spec.rb index 3c6c268e13a..cfbaadf0781 100644 --- a/drivers/hmis/spec/requests/hmis/assessments/submit_household_assessments_spec.rb +++ b/drivers/hmis/spec/requests/hmis/assessments/submit_household_assessments_spec.rb @@ -23,9 +23,9 @@ let(:c3) { create :hmis_hud_client, data_source: ds1, user: u1 } let(:c4) { create :hmis_hud_client, data_source: ds1, user: u1 } let!(:e1) { create :hmis_hud_enrollment, data_source: ds1, project: p1, client: c1, user: u1, entry_date: 2.weeks.ago } - let!(:e2) { create :hmis_hud_enrollment, data_source: ds1, project: p1, client: c2, user: u1, entry_date: 2.weeks.ago, household_id: e1.household_id, relationship_to_ho_h: 99 } - let!(:e3) { create :hmis_hud_enrollment, data_source: ds1, project: p1, client: c3, user: u1, entry_date: 2.weeks.ago, household_id: e1.household_id, relationship_to_ho_h: 99 } - let!(:e4) { create :hmis_hud_enrollment, data_source: ds1, project: p1, client: c4, user: u1, entry_date: 2.weeks.ago, relationship_to_ho_h: 99 } + let!(:e2) { create :hmis_hud_enrollment, data_source: ds1, project: p1, client: c2, user: u1, entry_date: 2.weeks.ago, household_id: e1.household_id, relationship_to_ho_h: 5 } + let!(:e3) { create :hmis_hud_enrollment, data_source: ds1, project: p1, client: c3, user: u1, entry_date: 2.weeks.ago, household_id: e1.household_id, relationship_to_ho_h: 5 } + let!(:e4) { create :hmis_hud_enrollment, data_source: ds1, project: p1, client: c4, user: u1, entry_date: 2.weeks.ago, relationship_to_ho_h: 5 } let!(:fd1) do ['informationDate', 'fieldOne', 'fieldTwo'].each do |key| create(:hmis_custom_data_element_definition, key: key, owner_type: Hmis::Hud::CustomAssessment.sti_name, data_source: ds1) diff --git a/drivers/hmis/spec/requests/hmis/assessments/submit_hud_assessments_spec.rb b/drivers/hmis/spec/requests/hmis/assessments/submit_hud_assessments_spec.rb index 6185f4927a6..0ab8a1b07c7 100644 --- a/drivers/hmis/spec/requests/hmis/assessments/submit_hud_assessments_spec.rb +++ b/drivers/hmis/spec/requests/hmis/assessments/submit_hud_assessments_spec.rb @@ -286,8 +286,8 @@ def expect_assessment_dates(assessment, expected_assessment_date:, expected_entr let!(:c3) { create :hmis_hud_client, data_source: ds1, user: u1 } let!(:c4) { create :hmis_hud_client, data_source: ds1, user: u1 } let!(:hoh_enrollment) { create :hmis_hud_enrollment, data_source: ds1, project: p1, client: c2, user: u1, entry_date: '2000-01-01' } - let!(:open_enrollment) { create :hmis_hud_enrollment, data_source: ds1, project: p1, client: c3, user: u1, entry_date: '2000-01-01', household_id: hoh_enrollment.household_id, relationship_to_ho_h: 99 } - let!(:open_enrollment2) { create :hmis_hud_enrollment, data_source: ds1, project: p1, client: c4, user: u1, entry_date: '2000-01-01', household_id: hoh_enrollment.household_id, relationship_to_ho_h: 99 } + let!(:open_enrollment) { create :hmis_hud_enrollment, data_source: ds1, project: p1, client: c3, user: u1, entry_date: '2000-01-01', household_id: hoh_enrollment.household_id, relationship_to_ho_h: 5 } + let!(:open_enrollment2) { create :hmis_hud_enrollment, data_source: ds1, project: p1, client: c4, user: u1, entry_date: '2000-01-01', household_id: hoh_enrollment.household_id, relationship_to_ho_h: 5 } let(:definition) { Hmis::Form::Definition.find_by(role: :EXIT) } it 'fails if exiting HoH member' do @@ -325,7 +325,7 @@ def expect_assessment_dates(assessment, expected_assessment_date:, expected_entr describe 'Submitting an Intake assessment in a WIP household' do let!(:hoh_enrollment) { create :hmis_hud_wip_enrollment, data_source: ds1, project: p1, user: u1, entry_date: today - 1.day } - let!(:other_enrollment) { create :hmis_hud_wip_enrollment, data_source: ds1, project: p1, user: u1, entry_date: '2000-01-01', household_id: hoh_enrollment.household_id, relationship_to_ho_h: 99 } + let!(:other_enrollment) { create :hmis_hud_wip_enrollment, data_source: ds1, project: p1, user: u1, entry_date: '2000-01-01', household_id: hoh_enrollment.household_id, relationship_to_ho_h: 5 } let(:assessment_date) { today.to_fs(:db) } let(:definition) { Hmis::Form::Definition.find_by(role: :INTAKE) } diff --git a/drivers/hmis/spec/requests/hmis/lookup_client_enrollment_spec.rb b/drivers/hmis/spec/requests/hmis/lookup_client_enrollment_spec.rb index aad13da5262..bdb5c046b62 100644 --- a/drivers/hmis/spec/requests/hmis/lookup_client_enrollment_spec.rb +++ b/drivers/hmis/spec/requests/hmis/lookup_client_enrollment_spec.rb @@ -311,6 +311,8 @@ expect(enrollment['currentUnit']['id']).to eq(unit1.id.to_s) expect(enrollment['numUnitsAssignedToHousehold']).to eq(2) end + # add: resolve nil relationship to hoh + # add: resolve 99 relationship to hoh end end diff --git a/drivers/hmis/spec/requests/hmis/update_relationship_to_ho_h_spec.rb b/drivers/hmis/spec/requests/hmis/update_relationship_to_ho_h_spec.rb index d6cc058021c..cc3307b971c 100644 --- a/drivers/hmis/spec/requests/hmis/update_relationship_to_ho_h_spec.rb +++ b/drivers/hmis/spec/requests/hmis/update_relationship_to_ho_h_spec.rb @@ -57,7 +57,7 @@ expect(enrollment).to be_present expect(errors).to be_empty expect(Hmis::Hud::Enrollment.all).to contain_exactly( - have_attributes(personal_id: c1.personal_id, relationship_to_ho_h: 99), + have_attributes(personal_id: c1.personal_id, relationship_to_ho_h: 3), have_attributes(personal_id: c2.personal_id, relationship_to_ho_h: 2), have_attributes(personal_id: c3.personal_id, relationship_to_ho_h: 1), ) @@ -67,7 +67,7 @@ context 'with Move-in Dates and Move-in Addresses' do let(:hoh_move_in_date) { 2.weeks.ago.to_date } let!(:hoh) { create :hmis_hud_enrollment, entry_date: 1.month.ago, move_in_date: hoh_move_in_date, relationship_to_ho_h: 1, data_source: ds1, project: p1 } - let!(:hhm) { create :hmis_hud_enrollment, entry_date: 1.month.ago, relationship_to_ho_h: 2, household_id: hoh.household_id, data_source: ds1, project: p1 } + let!(:hhm) { create :hmis_hud_enrollment, entry_date: 1.month.ago, relationship_to_ho_h: 3, household_id: hoh.household_id, data_source: ds1, project: p1 } let!(:hhm2) { create :hmis_hud_enrollment, entry_date: 1.month.ago, relationship_to_ho_h: 2, household_id: hoh.household_id, data_source: ds1, project: p1 } let(:input) do @@ -86,9 +86,9 @@ def perform_mutation context 'when new HoH entered before move-in' do it 'should transfer move-in date to new HoH' do - expect { perform_mutation }.to change { hoh.relationship_to_ho_h }.from(1).to(99). + expect { perform_mutation }.to change { hoh.relationship_to_ho_h }.from(1).to(3). and change { hoh.move_in_date }.from(hoh_move_in_date).to(nil). - and change { hhm.relationship_to_ho_h }.from(2).to(1). + and change { hhm.relationship_to_ho_h }.from(3).to(1). and change { hhm.move_in_date }.from(nil).to(hoh_move_in_date) end end @@ -98,9 +98,9 @@ def perform_mutation # TODO(#6857) update pending HUD guidance it 'should not transfer move-in date to new HoH' do - expect { perform_mutation }.to change { hoh.relationship_to_ho_h }.from(1).to(99). + expect { perform_mutation }.to change { hoh.relationship_to_ho_h }.from(1).to(3). and(not_change { hoh.move_in_date }). # old HoH still has old move-in date - and(change { hhm.relationship_to_ho_h }.from(2).to(1)). + and(change { hhm.relationship_to_ho_h }.from(3).to(1)). and(not_change { hhm.move_in_date }) # still nil end end @@ -109,9 +109,9 @@ def perform_mutation before(:each) { hhm.update!(entry_date: hoh_move_in_date - 2.days) } it 'should transfer move-in date to new HoH' do - expect { perform_mutation }.to change { hoh.relationship_to_ho_h }.from(1).to(99). + expect { perform_mutation }.to change { hoh.relationship_to_ho_h }.from(1).to(3). and change { hoh.move_in_date }.from(hoh_move_in_date).to(nil). - and change { hhm.relationship_to_ho_h }.from(2).to(1). + and change { hhm.relationship_to_ho_h }.from(3).to(1). and change { hhm.move_in_date }.from(nil).to(hoh_move_in_date) end end @@ -120,9 +120,9 @@ def perform_mutation let!(:move_in_address) { create :hmis_move_in_address, enrollment: hoh, data_source: ds1 } it 'should transfer Move-in Date and Move-in Address to new HoH' do - expect { perform_mutation }.to change { hoh.relationship_to_ho_h }.from(1).to(99). + expect { perform_mutation }.to change { hoh.relationship_to_ho_h }.from(1).to(3). and change { hoh.move_in_date }.from(hoh_move_in_date).to(nil). - and change { hhm.relationship_to_ho_h }.from(2).to(1). + and change { hhm.relationship_to_ho_h }.from(3).to(1). and change { hhm.reload.move_in_date }.from(nil).to(hoh_move_in_date). and change(hoh.move_in_addresses, :count).by(-1). and change(hhm.move_in_addresses, :count).by(1). @@ -138,11 +138,11 @@ def perform_mutation expect do perform_mutation hoh2.reload - end.to change { hoh.relationship_to_ho_h }.from(1).to(99). - and change { hoh2.relationship_to_ho_h }.from(1).to(99). + end.to change { hoh.relationship_to_ho_h }.from(1).to(3). + and change { hoh2.relationship_to_ho_h }.from(1).to(3). and change { hoh.move_in_date }.from(hoh_move_in_date).to(nil). and change { hoh2.move_in_date }.from(hoh2_move_in_date).to(nil). - and change { hhm.relationship_to_ho_h }.from(2).to(1). + and change { hhm.relationship_to_ho_h }.from(3).to(1). and change { hhm.move_in_date }.from(nil).to(hoh2_move_in_date) end end @@ -154,9 +154,9 @@ def perform_mutation end it 'should clear move-in date values' do - expect { perform_mutation }.to change { hoh.relationship_to_ho_h }.from(1).to(99). + expect { perform_mutation }.to change { hoh.relationship_to_ho_h }.from(1).to(3). and change { hoh.move_in_date }.from(hoh_move_in_date).to(nil). - and change { hhm.relationship_to_ho_h }.from(2).to(1). + and change { hhm.relationship_to_ho_h }.from(3).to(1). and change { hhm.move_in_date }.to(hoh_move_in_date). and change { hhm2.move_in_date }.to(nil) end @@ -175,7 +175,7 @@ def perform_mutation errors = result.dig('data', 'updateRelationshipToHoH', 'errors') expect(enrollment).to be_present expect(errors).to be_empty - expect(e1.reload.relationship_to_hoh).to eq(99) + expect(e1.reload.relationship_to_hoh).to eq(5) expect(e3.reload.relationship_to_hoh).to eq(1) end end @@ -231,21 +231,35 @@ def perform_mutation end end - it 'should warn if HoH is a child' do - c3.update(dob: 13.years.ago) - response, result = post_graphql(input: test_input.merge(confirmed: false)) { mutation } + context 'when new HoH is a child' do + before(:each) do + c3.update!(dob: 13.years.ago) + e3.update!(relationship_to_ho_h: 2) # child + end - aggregate_failures 'checking response' do - expect(response.status).to eq(200), result.inspect - enrollment = result.dig('data', 'updateRelationshipToHoH', 'enrollment') - errors = result.dig('data', 'updateRelationshipToHoH', 'errors') - expect(enrollment).to be nil - expect(errors).to match([ - a_hash_including('severity' => 'warning', 'fullMessage' => Hmis::HohChangeHandler.change_hoh_message(c1, c3)), - a_hash_including('severity' => 'warning', 'fullMessage' => Hmis::HohChangeHandler.child_hoh_message), - ]) - e3.reload - expect(e3.relationship_to_ho_h).not_to eq(1) + it 'should warn about new HoH being a child' do + response, result = post_graphql(input: test_input.merge(confirmed: false)) { mutation } + + aggregate_failures 'checking response' do + expect(response.status).to eq(200), result.inspect + enrollment = result.dig('data', 'updateRelationshipToHoH', 'enrollment') + errors = result.dig('data', 'updateRelationshipToHoH', 'errors') + expect(enrollment).to be nil + expect(errors).to contain_exactly( + a_hash_including('severity' => 'warning', 'fullMessage' => Hmis::HohChangeHandler.change_hoh_message(c1, c3)), + a_hash_including('severity' => 'warning', 'fullMessage' => Hmis::HohChangeHandler.child_hoh_message), + ) + e3.reload + expect(e3.relationship_to_ho_h).not_to eq(1) + end + end + + it 'should infer relationship to new HoH as 4 (Other relative)' do + expect do + response, result = post_graphql(input: test_input) { mutation } + expect(response.status).to eq(200), result.inspect + end.to change { e3.reload.relationship_to_ho_h }.from(2).to(1). + and change { e1.reload.relationship_to_ho_h }.from(1).to(4) end end @@ -287,17 +301,17 @@ def perform_mutation it 'should error if unauthorized' do remove_permissions(access_control, :can_edit_enrollments) - expect_gql_error post_graphql(input: test_input) { mutation }, message: 'access denied' + expect_access_denied post_graphql(input: test_input) { mutation } end it 'should error if user does not have access to enrollment' do remove_permissions(access_control, :can_view_enrollment_details) remove_permissions(access_control, :can_edit_enrollments) - expect_gql_error post_graphql(input: test_input) { mutation }, message: 'access denied' + expect_access_denied post_graphql(input: test_input) { mutation } end it 'should error if enrollment does not exist' do - expect_gql_error post_graphql(input: test_input.merge(enrollment_id: '0')) { mutation }, message: 'access denied' + expect_access_denied post_graphql(input: test_input.merge(enrollment_id: '0')) { mutation } end end diff --git a/drivers/hmis/spec/system/hmis/enrollment_management_spec.rb b/drivers/hmis/spec/system/hmis/enrollment_management_spec.rb index 26ec3f3fd6c..08fd42ca083 100644 --- a/drivers/hmis/spec/system/hmis/enrollment_management_spec.rb +++ b/drivers/hmis/spec/system/hmis/enrollment_management_spec.rb @@ -43,7 +43,7 @@ def search_for_client(client) def make_household(household_id: Hmis::Hud::Base.generate_uuid, enrollment_factory:) [ [c1, { RelationshipToHoH: 1 }], - [c2, { RelationshipToHoH: 99 }], + [c2, { RelationshipToHoH: 3 }], ].each do |client, enrollment_attrs| enrollment_attrs.merge!( client: client, @@ -106,6 +106,8 @@ def make_household(household_id: Hmis::Hud::Base.generate_uuid, enrollment_facto assert_text(c2.brief_name) assert_text(c1.brief_name) + e1 = c1.enrollments.sole + e2 = c2.enrollments.sole # choose second row. These radio selects need better a11y # find(:xpath, "//table/tbody/tr[2]/td/*[normalize-space()='HoH']").click within(:xpath, '//table/tbody/tr[2]') do @@ -117,7 +119,9 @@ def make_household(household_id: Hmis::Hud::Base.generate_uuid, enrollment_facto within(:xpath, '//table/tbody/tr[2]') do with_hidden { expect(page).to have_checked_field('HoH') } end - end.to change(c2.enrollments.where(relationship_to_hoh: 1), :count).by(1) + end.to change(c2.enrollments.where(relationship_to_hoh: 1), :count).by(1). + and change { e2.reload.relationship_to_hoh }.from(3).to(1). # c2 becomes HoH + and change { e1.reload.relationship_to_hoh }.from(1).to(3) # c1 has inferred relationship to c2 end it 'can remove a non-HoH member' do diff --git a/drivers/hmis_external_apis/app/views/hmis_external_apis/external_forms/form/_geolocation.haml b/drivers/hmis_external_apis/app/views/hmis_external_apis/external_forms/form/_geolocation.haml index a68e2e1f2c5..05d72bc475e 100644 --- a/drivers/hmis_external_apis/app/views/hmis_external_apis/external_forms/form/_geolocation.haml +++ b/drivers/hmis_external_apis/app/views/hmis_external_apis/external_forms/form/_geolocation.haml @@ -97,7 +97,15 @@ getLocationButton.click(function(event) { event.preventDefault(); setLoading(true); - navigator.geolocation.getCurrentPosition(setPosition, onError); + navigator.geolocation.getCurrentPosition( + setPosition, + onError, + { + timeout: 10000, // wait up to 10 seconds before showing an error message + maximumAge: 180000, // allow the browser to use a cached location if it's less than 3 minutes old + // choosing not to enable `enableHighAccuracy` because it can be slow and drain battery + } + ); }); clearButton.click(function(event) { diff --git a/drivers/hud_spm_report/app/controllers/hud_spm_report/base_controller.rb b/drivers/hud_spm_report/app/controllers/hud_spm_report/base_controller.rb index 95a1438e78f..65051cd5d5b 100644 --- a/drivers/hud_spm_report/app/controllers/hud_spm_report/base_controller.rb +++ b/drivers/hud_spm_report/app/controllers/hud_spm_report/base_controller.rb @@ -11,13 +11,14 @@ class BaseController < ::HudReports::BaseController def available_report_versions { 'FY 2020' => { slug: :fy2020, active: false }, - 'FY 2023 (current)' => { slug: :fy2023, active: true }, + 'FY 2023' => { slug: :fy2023, active: false }, + 'FY 2024 (current)' => { slug: :fy2024, active: true }, }.freeze end helper_method :available_report_versions def default_report_version - :fy2023 + :fy2024 end private def relevant_project_types @@ -96,6 +97,7 @@ def path_for_report_download(report, args) { fy2020: HudSpmReport::Generators::Fy2020::Generator, fy2023: HudSpmReport::Generators::Fy2023::Generator, + fy2024: HudSpmReport::Generators::Fy2024::Generator, } end end diff --git a/drivers/hud_spm_report/app/models/hud_spm_report.rb b/drivers/hud_spm_report/app/models/hud_spm_report.rb index 1b65d6f676b..7e80807a6ae 100644 --- a/drivers/hud_spm_report/app/models/hud_spm_report.rb +++ b/drivers/hud_spm_report/app/models/hud_spm_report.rb @@ -6,6 +6,6 @@ module HudSpmReport def self.current_generator - HudSpmReport::Generators::Fy2023::Generator + HudSpmReport::Generators::Fy2024::Generator end end diff --git a/drivers/hud_spm_report/app/models/hud_spm_report/document_exports/hud_spm_report_export.rb b/drivers/hud_spm_report/app/models/hud_spm_report/document_exports/hud_spm_report_export.rb index 9141897c39d..5103613ca1a 100644 --- a/drivers/hud_spm_report/app/models/hud_spm_report/document_exports/hud_spm_report_export.rb +++ b/drivers/hud_spm_report/app/models/hud_spm_report/document_exports/hud_spm_report_export.rb @@ -21,6 +21,7 @@ def generator_url [ HudSpmReport::Generators::Fy2020::Generator, HudSpmReport::Generators::Fy2023::Generator, + HudSpmReport::Generators::Fy2024::Generator, ] end end diff --git a/drivers/hud_spm_report/app/models/hud_spm_report/fy2024/bed_night.rb b/drivers/hud_spm_report/app/models/hud_spm_report/fy2024/bed_night.rb new file mode 100644 index 00000000000..719c3624fa5 --- /dev/null +++ b/drivers/hud_spm_report/app/models/hud_spm_report/fy2024/bed_night.rb @@ -0,0 +1,16 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +module HudSpmReport::Fy2024 + class BedNight < GrdaWarehouseBase + self.table_name = 'hud_report_spm_bed_nights' + + belongs_to :client, class_name: 'GrdaWarehouse::Hud::Client' + belongs_to :enrollment, class_name: 'HudSpmReport::Fy2024::SpmEnrollment' + belongs_to :episode, optional: true + belongs_to :service, class_name: 'GrdaWarehouse::Hud::Service', optional: true + end +end diff --git a/drivers/hud_spm_report/app/models/hud_spm_report/fy2024/detail.rb b/drivers/hud_spm_report/app/models/hud_spm_report/fy2024/detail.rb new file mode 100644 index 00000000000..ef43087f2b0 --- /dev/null +++ b/drivers/hud_spm_report/app/models/hud_spm_report/fy2024/detail.rb @@ -0,0 +1,36 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +module HudSpmReport::Fy2024::Detail + extend ActiveSupport::Concern + + included do + private_class_method def self.header_label(col) + case col.to_s + when 'client_id' + 'Warehouse Client ID' + when 'personal_id', 'enrollment.personal_id', 'exit_enrollment.personal_id' + 'HMIS Personal ID' + when 'enrollment.first_name', 'exit_enrollment.first_name' + 'First Name' + when 'enrollment.last_name', 'exit_enrollment.last_name' + 'Last Name' + when 'exit_enrollment.enrollment.project.project_name' + 'Exited Project Name' + when 'return_enrollment.enrollment.project.project_name' + 'Returned Project Name' + when 'data_source_id' + 'Data Source ID' + when 'los_under_threshold' + 'LOS Under Threshold' + when 'previous_street_essh' + 'Previous Street ESSH' + else + col.humanize + end + end + end +end diff --git a/drivers/hud_spm_report/app/models/hud_spm_report/fy2024/enrollment_link.rb b/drivers/hud_spm_report/app/models/hud_spm_report/fy2024/enrollment_link.rb new file mode 100644 index 00000000000..7a159ee8ae1 --- /dev/null +++ b/drivers/hud_spm_report/app/models/hud_spm_report/fy2024/enrollment_link.rb @@ -0,0 +1,13 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +module HudSpmReport::Fy2024 + class EnrollmentLink < GrdaWarehouseBase + self.table_name = 'hud_report_spm_enrollment_links' + belongs_to :enrollment, class_name: 'HudSpmReport::Fy2024::SpmEnrollment' + belongs_to :episode, optional: true + end +end diff --git a/drivers/hud_spm_report/app/models/hud_spm_report/fy2024/episode.rb b/drivers/hud_spm_report/app/models/hud_spm_report/fy2024/episode.rb new file mode 100644 index 00000000000..8572403eff0 --- /dev/null +++ b/drivers/hud_spm_report/app/models/hud_spm_report/fy2024/episode.rb @@ -0,0 +1,334 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +module HudSpmReport::Fy2024 + class Episode < GrdaWarehouseBase + self.table_name = 'hud_report_spm_episodes' + include Detail + + belongs_to :client, class_name: 'GrdaWarehouse::Hud::Client' + + has_many :enrollment_links + has_many :enrollments, through: :enrollment_links + has_many :bed_nights + + has_many :hud_reports_universe_members, inverse_of: :universe_membership, class_name: 'HudReports::UniverseMember', foreign_key: :universe_membership_id + + attr_accessor :report # FIXME? + attr_writer :filter, :services + + def self.detail_headers + client_columns = ['client_id', 'enrollment.first_name', 'enrollment.last_name', 'enrollment.personal_id'] + hidden_columns = ['id', 'report_instance_id'] + client_columns + columns = client_columns + (column_names - hidden_columns) + columns.map do |col| + [col, header_label(col)] + end.to_h + end + + def enrollment + enrollments.first + end + + # TODO: convert include_self_reported_and_ph to include_self_report_from_project_types so we can be explicit about which types we want to use when looking for time prior to entry + def compute_episode(enrollments, included_project_types:, excluded_project_types:, include_self_reported_and_ph:) + raise 'Client undefined' unless client.present? + + calculated_bed_nights = candidate_bed_nights(enrollments, included_project_types, include_self_reported_and_ph) + excluded_dates = excluded_bed_nights(enrollments, excluded_project_types) + allowed_bed_nights = calculated_bed_nights.reject { |_, _, date| date.in?(excluded_dates) } + + # when including 3.917 add days before entry from PH projects where entry was from a literally homeless situation + # Step 1 + # c. Measure 1b: In addition to the clients identified in a) and b) above, lines 1 and 2 in measure 1b include clients experiencing homelessness in a permanent housing project (project types 3, 9, 10, and 13) during the report year. These stays are defined as those active in any permanent housing project where all of the following are true: + # The stay meets the Identifying Clients Experiencing Literal Homelessness at Project Entry criteria + # And ( + # ( [project start date] >= [report start date] and [project start date] <= [report end date] ) + # Or + # ( [housing move-in date] >= [report start date] and [housing move-in date] <= [report end date] ) + # Or + # ( [housing move-in date] is null and [project exit date] >= [report start date] and [project exit date] <= [report end date]) + pre_entry_inclusion_dates = if include_self_reported_and_ph + ph_enrollments = enrollments.select { |enrollment, _, _| enrollment.project_type.in?(HudUtility2024.project_type_number_from_code(:ph)) } + add_self_reported([], ph_enrollments).reject { |_, _, date| date.in?(excluded_dates) } + else + # when not including 3.917 + [] + end + # To be included, in both 1a and 1b you need a homeless enrollment overlapping the range + + # Step 1 + inclusion_dates = allowed_bed_nights + # 1b additionally checks time before entry for PH projects + inclusion_dates += pre_entry_inclusion_dates + # Throw out any with overlapping housed status + # This actually happened above, so we don't need to do this + # inclusion_dates.reject! { |_, _, date| date.in?(excluded_dates) } + # Step 2 D + # binding.pry if client.PersonalID.to_s == '644272' + return unless inclusion_dates.present? + + # Steps 3 and 4 + filtered_bed_nights = filter_episode(allowed_bed_nights) || [] + # step 5 + # 5. Measure 1b: For each relevant project stay the client’s response to Data Standards element 3.917.3 – Approximate date this episode of homelessness started – also represents time the client has experienced homelessness. + # Including pre-entry for homeless enrollments + # when including 3.917 add days before entry + pre_entry_dates = if include_self_reported_and_ph + add_self_reported([], enrollments).reject { |_, _, date| date.in?(excluded_dates) } + else + # when not including 3.917 + [] + end + filtered_bed_nights += pre_entry_dates + return unless filtered_bed_nights.present? + + bed_nights_array = [] + enrollment_links_array = [] + any_bed_nights_in_report_range = filtered_bed_nights.any? { |_, _, date| date.between?(report_start_date, report_end_date) } + # any_bed_nights_in_report_range = false + # filtered_bed_nights.each do |enrollment, service_id, date| + # bed_nights_array << BedNight.new( + # client_id: client.id, + # enrollment_id: enrollment.id, + # service_id: service_id, + # date: date, + # ) + # any_bed_nights_in_report_range = true if date.between?(report_start_date, report_end_date) + # end + + enrollment_ids = filtered_bed_nights.map { |enrollment, _, _| enrollment.id }.uniq + enrollment_ids.each do |enrollment_id| + enrollment_links_array << EnrollmentLink.new( + enrollment_id: enrollment_id, + ) + end + + dates = filtered_bed_nights.map(&:last) + assign_attributes( + first_date: dates.first, # dates is sorted in filter_bed_nights, so first/last should be min/max + last_date: dates.last, + days_homeless: filtered_bed_nights.count, + literally_homeless_at_entry: literally_homeless_at_entry(filtered_bed_nights, dates.first), + ) + + { + episode: self, + bed_nights: bed_nights_array, + enrollment_links: enrollment_links_array, + any_bed_nights_in_report_range: any_bed_nights_in_report_range, + } + end + + private def candidate_bed_nights(enrollments, project_types, include_self_reported_and_ph) + bed_nights = {} # Hash with date as key so we only get one candidate per overlapping night + enrollments = enrollments.select do |e| + # For PH projects, only stays meeting the Identifying Clients Experiencing Literal Homelessness at Project Entry criteria are included in time experiencing homelessness. + in_project_type = e.project_type.in?(project_types) + # Always drop PH that wasn't literally homeless at entry or not in report range + # NOTE: PH is never in the project types, but included because of include_self_reported_and_ph + if include_self_reported_and_ph && e.project_type.in?(HudUtility2024.project_type_number_from_code(:ph)) + enrollment_literally_homeless_at_entry(e) && include_ph_enrollment?(e) + else + in_project_type + end + end + enrollments.each do |enrollment| + if enrollment.project_type.in?(HudUtility2024.project_type_number_from_code(:es_nbn)) + next unless enrollment.enrollment.present? # Skip if the enrollment has disappeared (e.g., a concurrent import deleted it) + + # https://files.hudexchange.info/resources/documents/System-Performance-Measures-HMIS-Programming-Specifications-September-2023.pdf - p11 + # For ES-NbN, "bed night” means the separate bed night records dated between the client’s [project start date] and the + # lesser of the ([project exit date] – 1) or [report end date]. Bed night records dated on the client’s exit date + # represent an error in data entry. + first_night = enrollment.entry_date + last_night = if enrollment.exit_date.present? + [enrollment.exit_date - 1.day, report_end_date].min # Cannot have an bed night on the exit date + else + report_end_date # If no exit, cannot have a bed night after the report period + end + + # services shape `{ [EnrollmentID, PersonalID, data_source_id] => [2022-01-01, 2022-01-02] }` + # services are already filtered to bed nights + enrollment_services = @services[[enrollment.enrollment.EnrollmentID, enrollment.personal_id, enrollment.data_source_id]] + next bed_nights unless enrollment_services.present? + + bed_nights.merge!( + enrollment_services. + map.with_index do |date, i| + next unless date.between?(first_night, last_night) + + # return a triple [enrollment, id, date], using the index in place of the id so we don't need to load + # it from the DB (middle item needs to be unique within the enrollment) + [enrollment, i, date] + end.compact.group_by(&:last). + transform_values { |v| Array.wrap(v).last }, # Unique by date + ) + else + start_date = enrollment.entry_date + end_date = if enrollment.project_type.in?(HudUtility2024.project_type_number_from_code(:ph)) + # PH only gets days before move-in, if there is one + enrollment.move_in_date || enrollment.exit_date + else + enrollment.exit_date + end + # The exit day is not a bed-night + end_date -= 1.day if end_date.present? + # Don't include days after the end of the reporting period + end_date = [end_date, report_end_date].compact.min + (start_date .. end_date).map do |date| + bed_nights[date] = [enrollment, nil, date] + end + end + end + + bed_nights.values + end + + private def excluded_bed_nights(enrollments, project_types) + bed_nights = Set.new + enrollments = enrollments.select { |e| e.project_type.in?(project_types) } + enrollments.each do |enrollment| + if enrollment.project_type.in?(HudUtility2024.project_type_number_from_code(:th)) + # TH bed nights are not considered homeless + # The exit day, if present, is not a a bed night + end_date = if enrollment.exit_date + [enrollment.exit_date - 1.day, report_end_date].min + else + report_end_date + end + bed_nights += (enrollment.entry_date .. end_date).to_a + elsif enrollment.project_type.in?(HudUtility2024.project_type_number_from_code(:ph)) + # PH bed nights on or after move in are not considered homeless + next unless enrollment.move_in_date.present? + + # The exit day, if present, is not a a bed night + end_date = if enrollment.exit_date + [enrollment.exit_date - 1.day, report_end_date].min + else + report_end_date + end + bed_nights += (enrollment.move_in_date .. end_date).to_a + else + raise 'Unexpected project type, no exclusion rules' + end + end + + bed_nights + end + + private def add_self_reported(existing_bed_nights, enrollments) + bed_nights = existing_bed_nights.index_by(&:last) + enrollments.each do |enrollment| + if enrollment.project_type.in?(HudUtility2024.project_type_number_from_code(:es_nbn)) + # NbN only gets service nights in the report range and within the enrollment period + first_night = [report_start_date, enrollment.entry_date].max + last_night = if enrollment.exit_date.present? + [enrollment.exit_date - 1.day, report_end_date].min # Cannot have an bed night on the exit date + else + report_end_date # If no exit, cannot have a bed night after the report period + end + # services shape `{ [EnrollmentID, PersonalID, data_source_id] => [2022-01-01, 2022-01-02] }` + # services are already filtered to bed nights + enrollment_services = @services[[enrollment.enrollment.EnrollmentID, enrollment.personal_id, enrollment.data_source_id]] + next unless enrollment_services.present? + + earliest_bed_night = enrollment_services.select do |date| + date.between?(first_night, last_night) + end.min + next unless earliest_bed_night.present? + + # Self-reported dates earlier than the first bed night if present and the first bed night is on or after the lookback date + start_date = [enrollment.start_of_homelessness, earliest_bed_night].compact.min + # b. For night-by-night based shelter stays, determine the client’s [earliest bed night] dated >= [project start date] and <= [project exit date]. If [earliest bed night] >= [lookback stop date], then every night from [approximate date this episode of homelessness started] up to and including [earliest bed night] should also be considered nights experiencing homelessness. For example, a response of “9/16/2022” with the client’s earliest bed night of 11/15/2022 would effectively include bed nights for 9/16/2022, 9/17/2022, 9/18/2022… up to and including 11/15/2022. Naturally this does not mean the client was physically present at this specific shelter on these nights, but these dates are nonetheless included in the client’s total time experiencing homelessness. + next unless earliest_bed_night >= lookback_date && start_date < enrollment.entry_date + + (start_date .. earliest_bed_night).map do |date| + bed_nights[date] ||= [enrollment, nil, date] # Add the day if not already present + end + else + # Self-reported dates earlier than the entry date if present and the **project** start is on or after the lookback date + start_date = [enrollment.start_of_homelessness, enrollment.entry_date].compact.min + # a. For entry-exit based project stays, if the [project start date] is >= [lookback stop date], then every night from [approximate date this episode of homelessness started] up to and including [project start date] should also be considered nights experiencing homelessness, even if response in [approximate date this episode of homelessness started] extends prior to [lookback stop date]. For example, a response in [approximate date this episode of homelessness started] of “2/14/2022” with a [project start date] of 5/15/2022 would cause every night from 2/14/2022 through and including 5/15/2022 to be included in the client’s dataset of nights experiencing homelessness. + next unless enrollment.entry_date >= lookback_date && start_date < enrollment.entry_date + + (start_date .. enrollment.entry_date).map do |date| + bed_nights[date] ||= [enrollment, nil, date] # Add the day if not already present + end + end + end + + bed_nights.values + end + + # 3. Utilizing data selected in step 1 and modified in step 2, determine each client’s latest homeless bed night which is >= [report start date] and <= [report end date]. This date becomes that particular client’s [client end date]. + # a. This date should be no later than the end date of the report ([client end date] must be <= [report end date]), in the event a project stay extends past the [report end date]. + # b. For enrollments in entry exit emergency shelters, this date should be one day prior to the client’s exit date, or the [report end date], whichever is earlier. It cannot be the client’s exit date since that date does not represent a bed night. + # c. For enrollments in night-by-night emergency shelters, this date must be based on recorded bed nights and not on the client’s start or exit date. + # d. Measure 1b: Be sure not to include the [housing move-in date] itself, as this date does not represent a night experiencing homelessness. + # 4. For each active client, create a [client start date] which is 365 days prior to the [client end date] going back no further than the [lookback stop date]. + # a. [Client start date] = [client end date] – 365 days. + # b. A [client start date] will usually be prior to the [report start date]. + # @param bed_nights [[Enrollment, service_id, Date]] Array of candidate bed nights to be processed + private def filter_episode(calculated_bed_nights) + return unless calculated_bed_nights.present? + + calculated_bed_nights = calculated_bed_nights.sort_by(&:last) + client_end_date = calculated_bed_nights.last.last + client_start_date = client_end_date - 365.days + + # Include contiguous dates before the calculated client start date: + # First, find as close to the start date as possible in the array + index = 0 + index += 1 while calculated_bed_nights[index].last <= client_start_date + + # Then walk back until there is a break + index -= 1 while index.positive? && calculated_bed_nights[index - 1].last == calculated_bed_nights[index].last - 1.day + + # Finally, return the selected dates + calculated_bed_nights[index ..] + end + + private def literally_homeless_at_entry(bed_nights, first_date) + enrollment_literally_homeless_at_entry(bed_nights.detect { |_, _, date| date == first_date }.first) + end + + private def enrollment_literally_homeless_at_entry(enrollment) + return true if enrollment.project_type.in?([0, 1, 4, 8]) + + # See Identifying Clients Experiencing Literal Homelessness at Project Entry + enrollment.project_type.in?([2, 3, 9, 10, 13]) && + (enrollment.prior_living_situation.in?(100..199) || + (enrollment.previous_street_essh? && enrollment.prior_living_situation.in?(200..299) && enrollment.los_under_threshold?) || + (enrollment.previous_street_essh? && enrollment.prior_living_situation.in?(300..499) && enrollment.los_under_threshold?)) + end + + private def include_ph_enrollment?(enrollment) + # See Identifying Clients Experiencing Literal Homelessness at Project Entry + return false unless enrollment.project_type.in?([2, 3, 9, 10, 13]) + + enrollment.entry_date.between?(report_start_date, report_end_date) || + (enrollment.move_in_date.present? && enrollment.move_in_date.between?(report_start_date, report_end_date)) || + (enrollment.move_in_date.blank? && enrollment.exit_date.present? && enrollment.exit_date.between?(report_start_date, report_end_date)) + end + + private def report_start_date + filter.start + end + + private def lookback_date + report_start_date - 7.years + end + + private def report_end_date + filter.end + end + + private def filter + @filter ||= ::Filters::HudFilterBase.new(user_id: report.user.id).update(report.options) + end + end +end diff --git a/drivers/hud_spm_report/app/models/hud_spm_report/fy2024/episode_batch.rb b/drivers/hud_spm_report/app/models/hud_spm_report/fy2024/episode_batch.rb new file mode 100644 index 00000000000..12af95cda4a --- /dev/null +++ b/drivers/hud_spm_report/app/models/hud_spm_report/fy2024/episode_batch.rb @@ -0,0 +1,107 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +module HudSpmReport::Fy2024 + class EpisodeBatch + def initialize(enrollments, included_project_types, excluded_project_types, include_self_reported_and_ph, report) + @enrollments = enrollments # are SpmEnrollment + @included_project_types = included_project_types + @excluded_project_types = excluded_project_types + @include_self_reported_and_ph = include_self_reported_and_ph + @report = report + @filter = ::Filters::HudFilterBase.new(user_id: report.user.id).update(report.options) # loading a user does a DB lookup, so avoid it + end + + def calculate_batch(client_ids) + # Original preload which loads ALL services + # enrollments_for_clients = @enrollments.where(client_id: client_ids).preload(:client, enrollment: :services).group_by(&:client_id) + + # Same as the preload using includes/references so we can limit the scope of services included + # s_t = GrdaWarehouse::Hud::Service.arel_table + # enrollments_for_clients = @enrollments.where(client_id: client_ids).preload(:client). + # includes(enrollment: :services). + # references(enrollment: :services). + # where(s_t[:DateProvided].eq(nil).or(s_t[:DateProvided].between(@filter.start .. @filter.end))). + # group_by(&:client_id) + + # # One possible solution (poisoning the preload scope) + # spm_enrollments = @enrollments.where(client_id: client_ids).preload(:client, enrollment: [:project, :client]) + # source_enrollments = spm_enrollments.map(&:enrollment) + # scope = GrdaWarehouse::Hud::Service.bed_night.between(start_date: @filter.start, end_date: @filter.end) + # # Inject the services scope into the preload + # ::ActiveRecord::Associations::Preloader.new.preload(source_enrollments, :services, scope) + # source_enrollments.each { |record| record.public_send(:services) } + # enrollments_for_clients = spm_enrollments.group_by(&:client_id) + + # Services are really expensive to preload, for unknown reasons, however, the overall set of information we need is fairly small + enrollments_for_clients = @enrollments.where(client_id: client_ids).preload(:client, :enrollment).group_by(&:client_id) + batch_personal_ids = enrollments_for_clients.values.flatten.map(&:personal_id).uniq + # Load all bed nights for these clients regardless of enrollment; we'll look them up as necessary + # Bednights are indexed on `[EnrollmentID, PersonalID, data_source_id]` + batch_services = GrdaWarehouse::Hud::Service.bed_night. + between(start_date: nil, end_date: @filter.end). # We don't need anything after the report end date, but may need services before the start date + where(PersonalID: batch_personal_ids). # impose some basic limit so we don't load the entire set of services + pluck(:EnrollmentID, :PersonalID, :data_source_id, :DateProvided). + group_by { |r| r.shift(3) }. + transform_values(&:flatten) + + episodes = [] + bed_nights_per_episode = [] + enrollment_links_per_episode = [] + client_ids.each do |client_id| + client = enrollments_for_clients[client_id]&.first&.client + next unless client.present? + + episode_calculations = HudSpmReport::Fy2024::Episode.new(client: client, report: @report, filter: @filter, services: batch_services). + compute_episode( + enrollments_for_clients[client_id], + included_project_types: @included_project_types, + excluded_project_types: @excluded_project_types, + include_self_reported_and_ph: @include_self_reported_and_ph, + ) + # Ignore clients with no episode + next if episode_calculations.blank? + + # Ignore clients with no bed nights in report range + any_bed_nights_in_report_range = episode_calculations[:any_bed_nights_in_report_range] + next unless any_bed_nights_in_report_range + + episodes << episode_calculations[:episode] + bed_nights_per_episode << episode_calculations[:bed_nights] + enrollment_links_per_episode << episode_calculations[:enrollment_links] + end + + save_episodes!(episodes, bed_nights_per_episode, enrollment_links_per_episode) + + episodes + end + + # The associations seem to make imports run one at a time, so, they are passed separately in parallel arrays + # NOTE: `_bed_nights` are not currently in use in the UI, but might want to be enabled sometime in the future + # to expose supporting data + private def save_episodes!(episodes, _bed_nights, enrollment_links) + # Import the episodes + results = Episode.import!(episodes) + # Attach the associations to their episode + results.ids.each_with_index do |id, index| + # Disabled to avoid database growth in production (see BedNight.import! below) + # bn_for_episode = bed_nights[index] + # bed_nights[index] = bn_for_episode.map do |bn| + # bn.episode_id = id + # bn + # end + el_for_episode = enrollment_links[index] + enrollment_links[index] = el_for_episode.map do |el| + el.episode_id = id + el + end + end + # Import the associations + # BedNight.import!(bed_nights.flatten) # Disabled to avoid database growth in production + EnrollmentLink.import!(enrollment_links.flatten) + end + end +end diff --git a/drivers/hud_spm_report/app/models/hud_spm_report/fy2024/return.rb b/drivers/hud_spm_report/app/models/hud_spm_report/fy2024/return.rb new file mode 100644 index 00000000000..83953b54e3f --- /dev/null +++ b/drivers/hud_spm_report/app/models/hud_spm_report/fy2024/return.rb @@ -0,0 +1,112 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +module HudSpmReport::Fy2024 + class Return < GrdaWarehouseBase + self.table_name = 'hud_report_spm_returns' + include Detail + + belongs_to :report_instance, class_name: 'HudReports::ReportInstance' + belongs_to :client, class_name: 'GrdaWarehouse::Hud::Client' + belongs_to :exit_enrollment, class_name: 'HudSpmReport::Fy2024::SpmEnrollment' + belongs_to :return_enrollment, class_name: 'HudSpmReport::Fy2024::SpmEnrollment', optional: true + + has_many :hud_reports_universe_members, inverse_of: :universe_membership, class_name: 'HudReports::UniverseMember', foreign_key: :universe_membership_id + + def self.client_ids_with_permanent_exits(report, enrollments) + filter = ::Filters::HudFilterBase.new(user_id: report.user.id).update(report.options) + enrollments.where(exit_date: filter.start - 730.days .. filter.end - 730.days). + where(destination: HudUtility2024.permanent_destinations). + pluck(:client_id). + uniq + end + + def self.compute_returns(report, enrollments) + client_ids_with_permanent_exits(report, enrollments).each_slice(500) do |slice| + returns = [].tap do |a| + slice.each do |client_id| + computed_return = new(report_instance_id: report.id, client_id: client_id).compute_return(enrollments) + a << computed_return if computed_return.present? + end + end + import!(returns) + end + + where(report_instance_id: report.id) + end + + def self.detail_headers + client_columns = ['client_id', 'exit_enrollment.first_name', 'exit_enrollment.last_name', 'exit_enrollment.personal_id'] + hidden_columns = ['id', 'report_instance_id'] + client_columns + join_columns = ['exit_enrollment.enrollment.project.project_name', 'return_enrollment.enrollment.project.project_name'] + columns = client_columns + (column_names + join_columns - hidden_columns) + columns.map do |col| + [col, header_label(col)] + end.to_h + end + + def compute_return(enrollments) + client_enrollments = enrollments.where(client_id: client_id) + self.exit_enrollment = client_enrollments.where(exit_date: report_start_date - 730.days .. report_end_date - 730.days). + where(destination: HudUtility2024.permanent_destinations). + order(exit_date: :asc, entry_date: :asc). + first + return unless exit_enrollment.present? # If no exit, no return + + self.exit_date = exit_enrollment.exit_date + self.exit_destination = exit_enrollment.destination + self.project_type = exit_enrollment.project_type + + candidate_returns = client_enrollments.where(entry_date: exit_date..).order(entry_date: :asc) + self.return_enrollment = candidate_returns.detect do |enrollment| + # Can't match yourself + next false if enrollment.id == exit_enrollment.id + + enrollment.project_type.in?(HudUtility2024.homeless_project_type_numbers) || + (enrollment.project_type.in?(HudUtility2024.project_type_number_from_code(:ph)) && + enrollment.entry_date > exit_date + 14.days && + ! other_ph?(enrollment, candidate_returns)) + end + + if return_enrollment.present? + self.return_date = return_enrollment.entry_date + self.days_to_return = (return_date - exit_date).to_i + end + + self + end + + private def other_ph?(ph_enrollment, other_enrollments) + other_enrollments.map do |enrollment| + next if enrollment == ph_enrollment # Don't compare to ourselves + + end_date = if enrollment.exit_date.present? + [enrollment.exit_date + 14.days, report_end_date].min + else + report_end_date + end + enrollment.project_type.in?(HudUtility2024.project_type_number_from_code(:ph)) && + ph_enrollment.entry_date.between?(enrollment.entry_date + 1.day, end_date) + end.any? + end + + private def report_start_date + filter.start + end + + private def lookback_date + report_start_date - 7.years + end + + private def report_end_date + filter.end + end + + private def filter + @filter ||= ::Filters::HudFilterBase.new(user_id: report_instance.user.id).update(report_instance.options) + end + end +end diff --git a/drivers/hud_spm_report/app/models/hud_spm_report/fy2024/spm_enrollment.rb b/drivers/hud_spm_report/app/models/hud_spm_report/fy2024/spm_enrollment.rb new file mode 100644 index 00000000000..c4b3aa9836e --- /dev/null +++ b/drivers/hud_spm_report/app/models/hud_spm_report/fy2024/spm_enrollment.rb @@ -0,0 +1,343 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +module HudSpmReport::Fy2024 + class SpmEnrollment < GrdaWarehouseBase + self.table_name = 'hud_report_spm_enrollments' + include ArelHelper + include Detail + + include HasPiiAttributes + pii_attr :first_name + pii_attr :last_name + pii_attr :age + + belongs_to :report_instance, class_name: 'HudReports::ReportInstance' + belongs_to :client, class_name: 'GrdaWarehouse::Hud::Client' + belongs_to :current_income_benefits, class_name: 'GrdaWarehouse::Hud::IncomeBenefit', optional: true + belongs_to :previous_income_benefits, class_name: 'GrdaWarehouse::Hud::IncomeBenefit', optional: true + belongs_to :enrollment, class_name: 'GrdaWarehouse::Hud::Enrollment' + + has_many :enrollment_links + has_many :episodes, through: :enrollment_links + + has_many :hud_reports_universe_members, inverse_of: :universe_membership, class_name: 'HudReports::UniverseMember', foreign_key: :universe_membership_id + + scope :open_during_range, ->(range) do + a_t = arel_table + + # SPM only runs on residential projects, + # residential projects do not receive service on their exit date, + # exclude exit date + where(dates_overlaps_arel(range, a_t[:entry_date], a_t[:exit_date]), exit_date_included: false) + end + + # HMIS Standard Reporting Terminology Glossary 2024 active client method 2 + # ( + # /* The exit date is in the date range, regardless of any service records attached to that enrollment. */ + # [project exit date] >= [report start date] and [project exit date] <= [report end date] + # ) + # or ( + # /* The client entered before the end of the report range and has not yet exited, + # or has exited in the future relative to the report range and there is a service in the report range. */ + # [project start date] <= [report end date] + # and + # ( [project exit date] is null or [project exit date] > [report end date] ) + # and + # [date of service] >= [report start date] + # and + # [date of service] <= [report end date] + # and + # [date of service] >= [project start date] + scope :with_active_method_2_in_range, ->(range) do + services_cond = GrdaWarehouse::Hud::Service.arel_table.then do |table| + [ + table[:date_provided].between(range), + table[:date_provided].gteq(arel_table[:entry_date]), + # Bed nights cannot occur on the exit date, but CAN occur on the last day of the report + # using, less than but pushing the end date out to include report end + table[:date_provided].lt(cl(arel_table[:exit_date], range.last + 1.days)), + ].inject(&:and) + end + + cls_cond = GrdaWarehouse::Hud::CurrentLivingSituation.arel_table.then do |table| + [ + table[:information_date].between(range), + table[:information_date].gteq(arel_table[:entry_date]), + # CLS can occur on exit date or report end + table[:information_date].lteq(cl(arel_table[:exit_date], range.last)), + ].inject(&:and) + end + + # Projects using Method 2 must include Method 1 as a starting basis + ee_cond = HudSpmReport::Fy2024::SpmEnrollment.arel_table.then do |table| + [ + table[:project_type].not_in([1, 4]), # Not ES-NbN, or SO + dates_overlaps_arel(range, table[:entry_date], table[:exit_date], exit_date_included: false), + ].inject(&:and) + end + + left_outer_joins(enrollment: [:services, :current_living_situations]). + where(arel_table[:exit_date].between(range).or(services_cond.or(cls_cond).or(ee_cond))) + end + + # HMIS Standard Reporting Terminology Glossary 2024 active client method 5 + scope :with_active_method_5_in_range, ->(range) do + bed_night_cond = GrdaWarehouse::Hud::Service.arel_table.then do |table| + [ + table[:record_type].eq(HudUtility2024.record_type('Bed Night', true)), + table[:date_provided].between(range), + table[:date_provided].gteq(arel_table[:entry_date]), + # Bed nights cannot occur on the exit date, but CAN occur on the last day of the report + # using, less than but pushing the end date out to include report end + table[:date_provided].lt(cl(arel_table[:exit_date], range.last + 1.days)), + ].inject(&:and) + end + + nbn_cond = arel_table[:project_type].eq(1).and(bed_night_cond) + + ee_cond = HudSpmReport::Fy2024::SpmEnrollment.arel_table.then do |table| + [ + table[:project_type].in([0, 2, 3, 8, 9, 10, 13]), + dates_overlaps_arel(range, table[:entry_date], table[:exit_date], exit_date_included: false), + ].inject(&:and) + end + + left_outer_joins(enrollment: :services).where(nbn_cond.or(ee_cond)) + end + + scope :literally_homeless_at_entry_in_range, ->(range) do + where( + dates_overlaps_arel(range, arel_table[:entry_date], arel_table[:exit_date], exit_date_included: false). + and(arel_table[:project_type].in([0, 1, 4, 8]). + or(arel_table[:project_type].in([2, 3, 9, 10, 13]). + and(arel_table[:prior_living_situation].between(100..199). + or(arel_table[:previous_street_essh].eq(true). + and(arel_table[:prior_living_situation].between(200..299)). + and(arel_table[:los_under_threshold].eq(true))). + or(arel_table[:previous_street_essh].eq(true). + and(arel_table[:prior_living_situation].between(300..499)). + and(arel_table[:los_under_threshold].eq(true)))))), + ) + end + + HomelessnessInfo = Struct.new(:start_of_homelessness, :entry_date, :move_in_date, keyword_init: true) + + # Unlike, most HUD reports, there is not a single enrollment per report client, so the enrollment set + # is constructed outside of the question universe, and then to preserve the 1:1 relationship between clients + # and question universe members, the question universes either refer directly to an enrollment in this set, or + # to an aggregation object that refers to enrollments in this set. + def self.create_enrollment_set(report_instance) + filter = ::Filters::HudFilterBase.new(user_id: report_instance.user.id).update(report_instance.options) + enrollments = HudSpmReport::Adapters::ServiceHistoryEnrollmentFilter.new(report_instance).enrollments + household_infos = household(enrollments) + enrollments.preload(:client, :destination_client, :exit, :income_benefits_at_exit, :income_benefits_at_entry, :income_benefits, project: :funders).find_in_batches(batch_size: 500) do |batch| + members = [] + batch.each do |enrollment| + client = enrollment.client + next if client.blank? + + current_income_benefits = current_income_benefits(enrollment, filter.end) + previous_income_benefits = previous_income_benefits(enrollment, current_income_benefits&.information_date, filter.end) + household_info = household_infos[enrollment.household_id] || + HomelessnessInfo.new( + start_of_homelessness: enrollment.date_to_street_essh, + entry_date: enrollment.entry_date, + move_in_date: enrollment_own_move_in_date(enrollment), + ) # If there is no HoH, use the enrollment + members << { + report_instance_id: report_instance.id, + + first_name: client.first_name, + last_name: client.last_name, + client_id: enrollment.destination_client.id, + enrollment_id: enrollment.id, + + personal_id: client.personal_id, + data_source_id: enrollment.data_source_id, + age: client.age_on([filter.start, enrollment.entry_date].max), + start_of_homelessness: start_of_homelessness(filter, household_info, enrollment), + entry_date: enrollment.entry_date, + exit_date: enrollment&.exit&.exit_date, + move_in_date: move_in_date(household_info, enrollment), + project_type: enrollment.project.project_type, + eligible_funding: eligible_funding?(enrollment, filter.start, filter.end), + destination: enrollment.exit&.destination, + + prior_living_situation: enrollment.living_situation, + length_of_stay: enrollment.length_of_stay, + los_under_threshold: enrollment.los_under_threshold == 1, + previous_street_essh: enrollment.previous_street_essh == 1, + + current_income_benefits_id: current_income_benefits&.id, + current_earned_income: earned_income(current_income_benefits), + current_non_employment_income: non_employment_income(current_income_benefits), + current_total_income: total_income(current_income_benefits), + + previous_income_benefits_id: previous_income_benefits&.id, + previous_earned_income: earned_income(previous_income_benefits), + previous_non_employment_income: non_employment_income(previous_income_benefits), + previous_total_income: total_income(previous_income_benefits), + + # Pending: https://airtable.com/appFAz3WpgFmIJMm6/shr8TvO6KfAZ3mOJd/tblYhwasMJptw5fjj/viw7VMUmDdyDL70a7/rec59oPiPxyysL4nL + days_enrolled: ([enrollment&.exit&.exit_date, filter.end].compact.min - enrollment.entry_date).to_i + 1, # enter and exit on the same day == 1 day + } + end + import!(members) + end + end + + def self.detail_headers + client_columns = ['client_id', 'first_name', 'last_name', 'personal_id', 'data_source_id'] + hidden_columns = ['id', 'report_instance_id', 'previous_income_benefits_id', 'current_income_benefits_id', 'enrollment_id'] + client_columns + columns = client_columns + (column_names - hidden_columns) + columns.map do |col| + [col, header_label(col)] + end.to_h + end + + private_class_method def self.start_of_homelessness(filter, household_info, enrollment) + # If the HMIS also collects this element on children and unknown-age household members, their data should be used in measure 1b + return [enrollment.date_to_street_essh, enrollment.client&.dob].compact.max if enrollment.date_to_street_essh.present? + + # If the HMIS does not collect this element on child household members: + # • The data from the head of household’s response to 3.917 should be propagated to these children for the purposes of measure 1b. + # • This applies to any household member whose age is <= 17 (calculated according to the HMIS Reporting Glossary), regardless of their relationship to the head of household, but not clients of unknown age. + # • Only propagate the head of household’s data to children with the same [project start date] as the head of household. + age = enrollment.client.age_on([filter.start, enrollment.entry_date].max) + start_of_homelessness = if age.present? && age <= 17 && + enrollment.entry_date == household_info.entry_date + # Inherit start of homelessness from HoH for any household member aged <- 17 if they entered with the HoH + household_info.start_of_homelessness + else + enrollment.date_to_street_essh + end + # Start of homelessness is never before birth + start_of_homelessness = [start_of_homelessness, enrollment.client&.dob].compact.max if start_of_homelessness.present? + + start_of_homelessness + end + + private_class_method def self.move_in_date(household_info, enrollment) + # Use the client move in date if they are the HoH + return enrollment_own_move_in_date(enrollment) if enrollment.head_of_household? + # Don't inherit move in date if the client exited before the HoH moved in + return enrollment_own_move_in_date(enrollment) if enrollment.exit.present? && household_info.move_in_date.present? && enrollment.exit.exit_date <= household_info.move_in_date + # Use the client's entry date if a client entered the household after the HoH had already moved in + return enrollment.entry_date if household_info.move_in_date.present? && enrollment.entry_date > household_info.move_in_date + + # Otherwise, inherit move in date from HoH + household_info.move_in_date + end + + private_class_method def self.eligible_funding?(enrollment, start_date, end_date) + enrollment.project.funders.any? do |funder| + # Unroll open_between to allow preload + funder.funder.in?(HudUtility2024.spm_coc_funders.map(&:to_s)) && + # Unroll open_between to allow preload + (funder.end_date.nil? || funder.end_date >= start_date) && + (funder.start_date.nil? || funder.start_date <= end_date) + end + end + + private_class_method def self.total_income(income_benefit) + (income_benefit&.hud_total_monthly_income || 0).clamp(0..) + end + + private_class_method def self.earned_income(income_benefit) + (income_benefit&.earned_amount || 0).clamp(0..) + end + + private_class_method def self.non_employment_income(income_benefit) + (total_income(income_benefit) - earned_income(income_benefit)).clamp(0..) + end + + private_class_method def self.current_income_benefits(enrollment, end_date) + # Exit assessment for leavers, or most recent annual update within report range for stayers + if enrollment.exit.present? && enrollment.exit.exit_date <= end_date + enrollment.income_benefits_at_exit + else + # enrollment. + # income_benefits_annual_update. + # where(information_date: ..end_date). + # where(annual_update_window_sql(enrollment)). + # order(information_date: :desc). + # first + enrollment.income_benefits.select do |ib| + ib.data_collection_stage == 5 && ## Annual update + ib.information_date <= end_date && + date_in_annual_update_window?(ib.information_date, enrollment) + end.max_by(&:information_date) + end + end + + private_class_method def self.previous_income_benefits(enrollment, annual_date, end_date) + return enrollment.income_benefits_at_entry if enrollment.exit.present? && enrollment.exit.exit_date <= end_date + return enrollment.income_benefits_at_entry if annual_date.nil? # Return entry if no annual date + + # Most recent annual update on or before the renewal date, or the entry assessment + # enrollment. + # income_benefits_annual_update. + # where(information_date: ...annual_date). + # where(annual_update_window_sql(enrollment)). + # order(information_date: :desc). + # first + enrollment.income_benefits.select do |ib| + ib.data_collection_stage == 5 && ## Annual update + ib.information_date < annual_date && + date_in_annual_update_window?(ib.information_date, enrollment) + end.max_by(&:information_date) || enrollment.income_benefits_at_entry # Default to entry assessment if less than 2 years + end + + private_class_method def self.date_in_annual_update_window?(date, enrollment) + entry_date = enrollment.entry_date + interval = 30.days + elapsed_years = date.year - entry_date.year + window_date = entry_date + elapsed_years.years + + date.between?(window_date - interval, window_date + interval) + end + + private_class_method def self.annual_update_window_sql(enrollment) + # 30 days of anniversary of entry date + report_date = ib_t[:information_date].to_sql + entry_date = enrollment.entry_date.to_fs(:db) + interval = '30 days' + <<~SQL + (EXTRACT(MONTH FROM #{report_date}), EXTRACT(DAY FROM #{report_date})) IN ( + SELECT EXTRACT(MONTH FROM gs), EXTRACT(DAY FROM gs) + FROM generate_series( + '#{entry_date}'::date - INTERVAL '#{interval}', + '#{entry_date}'::date + INTERVAL '#{interval}', + '1 day' + ) AS gs + ) + SQL + end + + private_class_method def self.enrollment_own_move_in_date(enrollment) + return nil unless enrollment.move_in_date + + enrollment.move_in_date >= enrollment.entry_date ? enrollment.move_in_date : nil + end + + private_class_method def self.household(enrollments) + result = {} + + scope = enrollments.heads_of_households.order(e_t[:household_id], e_t[:move_in_date].asc.nulls_last) + scope.find_in_batches do |batch| + batch.each do |enrollment| + result[enrollment.household_id] = HomelessnessInfo.new( + start_of_homelessness: enrollment.date_to_street_essh, + entry_date: enrollment.entry_date, + move_in_date: enrollment_own_move_in_date(enrollment), + ) + end + end + result + end + end +end diff --git a/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/generator.rb b/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/generator.rb new file mode 100644 index 00000000000..83f7f805bbc --- /dev/null +++ b/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/generator.rb @@ -0,0 +1,70 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +module HudSpmReport::Generators::Fy2024 + class Generator < ::HudReports::GeneratorBase + cattr_accessor :write_detail_path + + def self.fiscal_year + 'FY 2024' + end + + def self.generic_title + 'System Performance Measures' + end + + def self.short_name + 'SPM'.freeze + end + + def self.default_project_type_codes + HudUtility2024.residential_project_type_numbers_by_code.keys + end + + def url + hud_reports_spm_url(report, { host: ENV['FQDN'], protocol: 'https' }) + end + + def self.questions + [ + HudSpmReport::Generators::Fy2024::MeasureOne, + HudSpmReport::Generators::Fy2024::MeasureTwo, + HudSpmReport::Generators::Fy2024::MeasureThree, + HudSpmReport::Generators::Fy2024::MeasureFour, + HudSpmReport::Generators::Fy2024::MeasureFive, + HudSpmReport::Generators::Fy2024::MeasureSix, + HudSpmReport::Generators::Fy2024::MeasureSeven, + HudSpmReport::Generators::Fy2024::HdxUpload, + ].map do |q| + [q.question_number, q] + end.to_h.freeze + end + + def self.filter_class + ::Filters::HudFilterBase + end + + def self.valid_question_number(question_number) + questions.keys.detect { |q| q == question_number } || questions.keys.first + end + + def self.client_class(question) + questions[question].client_class + end + + def self.pii_columns + ['enrollment.first_name', 'first_name', 'enrollment.last_name', 'last_name', 'dob', 'ssn'] + end + + def self.detail_template + 'hud_spm_report/cells/show' + end + + def self.uploadable_version? + true + end + end +end diff --git a/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/hdx_upload.rb b/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/hdx_upload.rb new file mode 100644 index 00000000000..12696329935 --- /dev/null +++ b/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/hdx_upload.rb @@ -0,0 +1,275 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +### +# Copyright 2016 - 2024 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +# HUD SPM Report Generator: Measure 2a and 2b: The Extent to which Persons Who Exit Homelessness +# to Permanent Housing Destinations Return to Homelessness within 6, 12, +# and 24 months. +module HudSpmReport::Generators::Fy2024 + class HdxUpload < MeasureBase + def self.question_number + 'HDX Upload'.freeze + end + + def self.table_descriptions + { + 'HDX Upload': 'CSV Upload for HDX 2.0', + }.freeze + end + + def run_question! + tables = [ + ['csv', :run_csv], + ] + + @report.start(self.class.question_number, tables.map(&:first)) + + tables.each do |name, msg| + send(msg, name) + end + + @report.complete(self.class.question_number) + end + + ROWS = { + 1 => 'Variable Name', + 2 => 'Variable Value', + }.freeze + + COLUMNS = { + A: ['CocCode', :metadata], + B: ['ReportDateTime', :metadata], + C: ['ReportStartDate', :metadata], + D: ['ReportEndDate', :metadata], + E: ['SoftwareName', :metadata], + F: ['SourceContactFirst', :metadata], + G: ['SourceContactLast', :metadata], + H: ['SourceContactEmail', :metadata], + + I: ['ESSHUniverse_1A', :spm, '1a', :B1], + J: ['ESSHAvgTime_1A', :spm, '1a', :D1], + K: ['ESSHMedianTime_1A', :spm, '1a', :G1], + L: ['ESSHTHUniverse_1A', :spm, '1a', :B2], + M: ['ESSHTHAvgTime_1A', :spm, '1a', :D2], + N: ['ESSHTHMedianTime_1A', :spm, '1a', :G2], + + O: ['ESSHUniverse_1B', :spm, '1b', :B1], + P: ['ESSHAvgTime_1B', :spm, '1b', :D1], + Q: ['ESSHMedianTime_1B', :spm, '1b', :G1], + R: ['ESSHTHUniverse_1B', :spm, '1b', :B2], + S: ['ESSHTHAvgTime_1B', :spm, '1b', :D2], + T: ['ESSHTHMedianTime_1B', :spm, '1b', :G2], + + U: ['SOExitPH_2', :spm, '2a and 2b', :B2], + V: ['SOReturn0to180_2', :spm, '2a and 2b', :C2], + W: ['SOReturn181to365_2', :spm, '2a and 2b', :E2], + X: ['SOReturn366to730_2', :spm, '2a and 2b', :G2], + Y: ['ESExitPH_2', :spm, '2a and 2b', :B3], + Z: ['ESReturn0to180_2', :spm, '2a and 2b', :C3], + AA: ['ESReturn181to365_2', :spm, '2a and 2b', :E3], + AB: ['ESReturn366to730_2', :spm, '2a and 2b', :G3], + AC: ['THExitPH_2', :spm, '2a and 2b', :B4], + AD: ['THReturn0to180_2', :spm, '2a and 2b', :C4], + AE: ['THReturn181to365_2', :spm, '2a and 2b', :E4], + AF: ['THReturn366to730_2', :spm, '2a and 2b', :G4], + AG: ['SHExitPH_2', :spm, '2a and 2b', :B5], + AH: ['SHReturn0to180_2', :spm, '2a and 2b', :C5], + AI: ['SHReturn181to365_2', :spm, '2a and 2b', :E5], + AJ: ['SHReturn366to730_2', :spm, '2a and 2b', :G5], + AK: ['PHExitPH_2', :spm, '2a and 2b', :B6], + AL: ['PHReturn0to180_2', :spm, '2a and 2b', :C6], + AM: ['PHReturn181to365_2', :spm, '2a and 2b', :E6], + AN: ['PHReturn366to730_2', :spm, '2a and 2b', :G6], + + AO: ['TotalAnnual_3', :spm, '3.2', :C2], + AP: ['ESAnnual_3', :spm, '3.2', :C3], + AQ: ['SHAnnual_3', :spm, '3.2', :C4], + AR: ['THAnnual_3', :spm, '3.2', :C5], + + AS: ['AdultStayers_4', :spm, '4.1', :C2], + AT: ['IncreaseEarned4_1', :spm, '4.1', :C3], + + AU: ['IncreaseOther4_2', :spm, '4.2', :C3], + + AV: ['IncreaseTotal4_3', :spm, '4.3', :C3], + + AW: ['AdultLeavers_4', :spm, '4.4', :C2], + AX: ['IncreaseEarned4_4', :spm, '4.4', :C3], + + AY: ['IncreaseOther4_5', :spm, '4.5', :C3], + + AZ: ['IncreaseTotal4_6', :spm, '4.6', :C3], + + BA: ['EnterESSHTH5_1', :spm, '5.1', :C2], + BB: ['ESSHTHWithPriorSvc5_1', :spm, '5.1', :C3], + + BC: ['EnterESSHTHPH5_2', :spm, '5.2', :C2], + BD: ['ESSHTHPHWithPriorSvc5_2', :spm, '5.2', :C3], + + BE: ['THExitPH_6', :spm, '6a.1 and 6b.1', :B4], + BF: ['THReturn0to180_6', :spm, '6a.1 and 6b.1', :C4], + BG: ['THReturn181to365_6', :spm, '6a.1 and 6b.1', :E4], + BH: ['THReturn366to730_6', :spm, '6a.1 and 6b.1', :G4], + BI: ['SHExitPH_6', :spm, '6a.1 and 6b.1', :B5], + BJ: ['SHReturn0to180_6', :spm, '6a.1 and 6b.1', :C5], + BK: ['SHReturn181to365_6', :spm, '6a.1 and 6b.1', :E5], + BL: ['SHReturn366to730_6', :spm, '6a.1 and 6b.1', :G5], + BM: ['PHExitPH_6', :spm, '6a.1 and 6b.1', :B6], + BN: ['PHReturn0to180_6', :spm, '6a.1 and 6b.1', :C6], + BO: ['PHReturn181to365_6', :spm, '6a.1 and 6b.1', :E6], + BP: ['PHReturn366to730_6', :spm, '6a.1 and 6b.1', :G6], + + BQ: ['SHTHRRHCat3Leavers_6', :spm, '6c.1', :C2], + BR: ['SHTHRRHCat3ExitPH_6', :spm, '6c.1', :C3], + + BS: ['PSHCat3Clients_6', :spm, '6c.2', :C2], + BT: ['PSHCat3StayOrExitPH_6', :spm, '6c.2', :C3], + + BU: ['SOExit_7', :spm, '7a.1', :C2], + BV: ['SOExitTempInst_7', :spm, '7a.1', :C3], + BW: ['SOExitPH_7', :spm, '7a.1', :C4], + + BX: ['ESSHTHRRHExit_7', :spm, '7b.1', :C2], + BY: ['ESSHTHRRHToPH_7', :spm, '7b.1', :C3], + + BZ: ['PHClients_7', :spm, '7b.2', :C2], + CA: ['PHClientsStayOrExitPH_7', :spm, '7b.2', :C3], + + CB: ['ESSH_UndupHMIS_DQ', :essh, 'Q1', :B2], + CC: ['TH_UndupHMIS_DQ', :th, 'Q1', :B2], + CD: ['PSHOPH_UndupHMIS_DQ', :pshoph, 'Q1', :B2], + CE: ['RRH_UndupHMIS_DQ', :rrh, 'Q1', :B2], + CF: ['StOutreach_UndupHMIS_DQ', :so, 'Q1', :B2], + CG: ['ESSH_LeaversHMIS_DQ', :essh, 'Q1', :B6], + CH: ['TH_LeaversHMIS_DQ', :th, 'Q1', :B6], + CI: ['PSHOPH_LeaversHMIS_DQ', :pshoph, 'Q1', :B6], + CJ: ['RRH_LeaversHMIS_DQ', :rrh, 'Q1', :B6], + CK: ['StOutreach_LeaversHMIS_DQ', :so, 'Q1', :B6], + + CL: ['ESSH_DkRMHMIS_DQ', :essh, 'Q4', :E2], + CM: ['TH_DkRMHMIS_DQ', :th, 'Q4', :E2], + CN: ['PSHOPH_DkRMHMIS_DQ', :pshoph, 'Q4', :E2], + CO: ['RRH_DkRMHMIS_DQ', :rrh, 'Q4', :E2], + CP: ['StOutreach_DkRMHMIS_DQ', :so, 'Q4', :E2], + }.freeze + + private def run_csv(table_name) + prepare_table( + table_name, + ROWS, + COLUMNS, + hide_column_header: true, # Column headers are part of the table + external_column_header: false, + external_row_label: true, + ) + + COLUMNS.transform_values(&:first).each do |column, label| + answer = @report.answer(question: table_name, cell: column.to_s + '1') + answer.update(summary: label) + end + + COLUMNS.each do |column, (label, section, *args)| + cell_value = case section + when :metadata + metadata(label) + when :spm + spm(*args) + else + dq(section, *args) + end + + answer = @report.answer(question: table_name, cell: column.to_s + '2') + answer.update(summary: cell_value) + end + end + + def metadata(column) + case column + when 'CocCode' + @report.coc_codes.join(', ') + when 'ReportDateTime' + @report.started_at.strftime('%Y-%m-%d %H:%M:%S') + when 'ReportStartDate' + @report.start_date.strftime('%Y-%m-%d') + when 'ReportEndDate' + @report.end_date.strftime('%Y-%m-%d') + when 'SoftwareName' + 'OpenPath HMIS Data Warehouse' + when 'SourceContactFirst' + @report.user.first_name + when 'SourceContactLast' + @report.user.last_name + when 'SourceContactEmail' + @report.user.email + end + end + + def spm(table_name, cell_name) + @report.answer(question: table_name, cell: cell_name)&.summary || '' + end + + def dq(section, table_name, cell_name) + return unless RailsDrivers.loaded.include?(:hud_apr) + + @attempted ||= Set.new + @reports ||= {} + + # prevent retrying reports that don't have any projects + # Return a string to indicate this cell has been processed + return '' if @attempted.include?(section) && @reports[section].nil? + + dq_report = case section + when :essh + @reports[section] ||= generate_dq(HudUtility2024.residential_project_type_numbers_by_codes(:es, :sh)) + when :th + @reports[section] ||= generate_dq(HudUtility2024.residential_project_type_numbers_by_codes(:th)) + when :pshoph + @reports[section] ||= generate_dq(HudUtility2024.residential_project_type_numbers_by_codes(:psh, :oph)) + when :rrh + @reports[section] ||= generate_dq(HudUtility2024.residential_project_type_numbers_by_codes(:rrh)) + when :so + @reports[section] ||= generate_dq(HudUtility2024.residential_project_type_numbers_by_codes(:so)) + end + + # prevent retrying reports that don't have any projects + @attempted << section + dq_report&.answer(question: table_name, cell: cell_name)&.summary || '' + end + + private def generate_dq(project_types) + dq_filter = filter.deep_dup + # The DQ version differs from the SPM version + dq_filter.report_version = :fy2024 + + # limit DQ report to projects in the appropriate project types that were in this SPM + project_ids = GrdaWarehouse::Hud::Project.where(ProjectType: project_types, id: @report.project_ids).pluck(:id) + return unless project_ids.any? + + # Clear out other mechanisms of setting projects + dq_filter.relevant_project_types = [] + dq_filter.project_type_codes = [] + dq_filter.project_type_numbers = [] + dq_filter.project_group_ids = [] + dq_filter.data_source_ids = [] + dq_filter.project_ids = project_ids + + generator = HudApr::Generators::Dq::Fy2024::Generator + report = ::HudReports::ReportInstance.from_filter(dq_filter, generator.title, build_for_questions: ['Question 1', 'Question 4']) + generator.new(report).run!(email: false, manual: false) + + report + end + + def filter + @filter ||= ::Filters::HudFilterBase.new(user_id: @report.user.id).update(@report.options) + end + end +end diff --git a/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_base.rb b/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_base.rb new file mode 100644 index 00000000000..fa9384677a6 --- /dev/null +++ b/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_base.rb @@ -0,0 +1,63 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +module HudSpmReport::Generators::Fy2024 + class MeasureBase < ::HudReports::QuestionBase + def self.client_class + HudSpmReport::Fy2024::SpmEnrollment + end + + private def enrollment_set + enrollments = @report.spm_enrollments + return enrollments if enrollments.exists? + + HudSpmReport::Fy2024::SpmEnrollment.create_enrollment_set(@report) + @report.spm_enrollments + end + + private def prepare_table(table_name, rows, cols, external_column_header: false, hide_column_header: false, external_row_label: false) + metadata = { + row_labels: rows.values, + first_column: cols.keys.first, + last_column: cols.keys.last, + first_row: rows.keys.first, + last_row: rows.keys.last, + external_column_header: external_column_header, + external_row_label: external_row_label, + } + metadata.merge!(header_row: [''] + cols.values) unless hide_column_header + @report.answer(question: table_name).update( + metadata: metadata, + ) + end + + private def spm_e_t + HudSpmReport::Fy2024::SpmEnrollment.arel_table + end + + private def percent(num, denom) + return '0.00' if denom.zero? + + format('%1.4f', ((num / denom.to_f) * 100).round(2)) + end + + private def filter + @filter ||= ::Filters::HudFilterBase.new(user_id: @report.user.id).update(@report.options) + end + + private def generator_klass + HudSpmReport::Generators::Fy2024::Generator + end + + private def write_detail(answer) + answer.write_detail( + path: generator_klass.write_detail_path, + generator: generator_klass, + question_name: self.class.question_number, + ) + end + end +end diff --git a/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_five.rb b/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_five.rb new file mode 100644 index 00000000000..ffcc8823321 --- /dev/null +++ b/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_five.rb @@ -0,0 +1,148 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +# HUD SPM Report Generator: Measure 2a and 2b: The Extent to which Persons Who Exit Homelessness +# to Permanent Housing Destinations Return to Homelessness within 6, 12, +# and 24 months. +module HudSpmReport::Generators::Fy2024 + class MeasureFive < MeasureBase + def self.question_number + 'Measure 5'.freeze + end + + def self.table_descriptions + { + 'Measure 5' => 'Number of Persons who Become Homeless for the First Time', + '5.1' => 'Change in the number of persons entering ES, SH, and TH projects with no prior enrollments in HMIS', + '5.2' => 'Change in the number of persons entering ES, SH, TH, and PH projects with no prior enrollments in HMIS', + }.freeze + end + + def run_question! + tables = [ + ['5.1', :run_5_1], + ['5.2', :run_5_2], + ] + + @report.start(self.class.question_number, tables.map(&:first)) + + tables.each do |name, msg| + send(msg, name) + end + + @report.complete(self.class.question_number) + end + + COLUMNS = { + 'B' => 'Previous FY', + 'C' => 'Current FY', + 'D' => 'Difference', + }.freeze + + private def run_5_1(table_name) + prepare_table( + table_name, + { + 2 => 'Universe: Person with entries into ES-EE, ES-NbN, SH, or TH during the reporting period.', + 3 => 'Of persons above, count those who were in ES-EE, ES-NbN, SH, TH, or any PH within 24 months prior to their start during the reporting year.', + 4 => 'Of persons above, count those who did not have entries in ES-EE, ES-NbN, SH, TH or PH in the previous 24 months. (i.e. number of persons experiencing homelessness for the first time)', + }, + COLUMNS, + ) + + report_members = create_universe(:m5_1, [:es, :sh, :th].map { |code| HudUtility2024.project_type_number_from_code(code) }.flatten) + answer = @report.answer(question: table_name, cell: 'C2') + answer.add_members(report_members) + answer.update(summary: report_members.count) + + prior_members = create_priors_universe(:m5_1p, report_members) + answer = @report.answer(question: table_name, cell: 'C3') + answer.add_members(prior_members) + answer.update(summary: prior_members.count) + + # include universe for performance metrics dependent report + first_time_members = report_members. + where.not(client_id: prior_members.preload(:universe_membership).map { |u| u.universe_membership.client_id }) + answer = @report.answer(question: table_name, cell: 'C4') + answer.update(summary: first_time_members.count) + answer.add_members(first_time_members) + end + + private def run_5_2(table_name) + prepare_table( + table_name, + { + 2 => 'Universe: Person with entries into ES-EE, ES-NbN, SH, TH or PH during the reporting period.', + 3 => 'Of persons above, count those who were in ES-EE, ES-NbN, SH, TH, or any PH within 24 months prior to their start during the reporting year.', + 4 => 'Of persons above, count those who did not have entries in ES-EE, ES-NbN, SH, TH or PH in the previous 24 months. (i.e. number of persons experiencing homelessness for the first time)', + }, + COLUMNS, + ) + + report_members = create_universe(:m5_2, [:es, :sh, :th, :ph].flat_map { |code| HudUtility2024.project_type_number_from_code(code) }) + answer = @report.answer(question: table_name, cell: 'C2') + answer.add_members(report_members) + answer.update(summary: report_members.count) + + prior_members = create_priors_universe(:m5_2p, report_members) + answer = @report.answer(question: table_name, cell: 'C3') + answer.add_members(prior_members) + answer.update(summary: prior_members.count) + + answer = @report.answer(question: table_name, cell: 'C4') + answer.update(summary: report_members.count - prior_members.count) + end + + def create_universe(universe_name, project_types) + filter = ::Filters::HudFilterBase.new(user_id: @report.user.id).update(@report.options) + universe = @report.universe(universe_name) + enrollments = enrollment_set.where(entry_date: filter.range, project_type: project_types) + earliest_enrollments = HudSpmReport::Fy2024::SpmEnrollment.one_for_column(:entry_date, source_arel_table: spm_e_t, group_on: :client_id, direction: :asc, scope: enrollments) + + members = earliest_enrollments.map do |enrollment| + [enrollment.client, enrollment] + end.to_h + universe.add_universe_members(members) + + universe.members + end + + def create_priors_universe(universe_name, report_members) + universe = @report.universe(universe_name) + + if report_members.count.positive? + report_enrollments = HudSpmReport::Fy2024::SpmEnrollment.where(id: report_members.select(:universe_membership_id)) + filter = ::Filters::HudFilterBase.new(user_id: @report.user.id).update(@report.options) + adjusted_range = filter.range.begin - 730.days .. filter.range.end + project_types = [:es, :sh, :th, :ph].flat_map { |code| HudUtility2024.project_type_number_from_code(code) } + candidate_enrollments = enrollment_set.open_during_range(adjusted_range).where(project_type: project_types) + + universe_enrollments = [] + report_enrollments.find_in_batches do |batch| + batch_candidates = candidate_enrollments. + where(spm_e_t[:client_id].in(batch.map(&:client_id))). + order(spm_e_t[:exit_date].desc).order(:id). + group_by(&:client_id) + + batch.each do |enrollment| + found = batch_candidates[enrollment.client_id]&.detect do |candidate| + candidate.entry_date < enrollment.entry_date && + (candidate.exit_date.nil? || candidate.exit_date >= enrollment.entry_date - 730.days) + end + universe_enrollments << found if found + end + end + + members = universe_enrollments.map do |enrollment| + [enrollment.client, enrollment] + end.to_h + universe.add_universe_members(members) + end + + universe.members + end + end +end diff --git a/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_four.rb b/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_four.rb new file mode 100644 index 00000000000..b84c5b2efbc --- /dev/null +++ b/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_four.rb @@ -0,0 +1,264 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +# HUD SPM Report Generator: Measure 2a and 2b: The Extent to which Persons Who Exit Homelessness +# to Permanent Housing Destinations Return to Homelessness within 6, 12, +# and 24 months. +module HudSpmReport::Generators::Fy2024 + class MeasureFour < MeasureBase + def self.question_number + 'Measure 4'.freeze + end + + def self.table_descriptions + { + 'Measure 4' => 'Employment and Income Growth for Homeless Persons in CoC Program-funded Projects', + '4.1' => 'Change in earned income for adult system stayers during the reporting period', + '4.2' => 'Change in non-employment cash income for adult system stayers during the reporting period', + '4.3' => 'Change in total income for adult system stayers during the reporting period', + '4.4' => 'Change in earned income for adult system leavers', + '4.5' => 'Change in non-employment cash income for adult system leavers', + '4.6' => 'Change in total income for adult system leavers', + }.freeze + end + + def run_question! + tables = [ + ['4.1', :run_4_1], + ['4.2', :run_4_2], + ['4.3', :run_4_3], + ['4.4', :run_4_4], + ['4.5', :run_4_5], + ['4.6', :run_4_6], + ] + + @report.start(self.class.question_number, tables.map(&:first)) + + tables.each do |name, msg| + send(msg, name) + end + + @report.complete(self.class.question_number) + end + + COLUMNS = { + 'B' => 'Previous FY', + 'C' => 'Current FY', + 'D' => 'Difference', + }.freeze + + private def run_4_1(table_name) + prepare_table( + table_name, + { + 2 => 'Universe: Number of adults (system stayers)', + 3 => 'Number of adults with increased earned income', + 4 => 'Percentage of adults who increased earned income', + }, + COLUMNS, + ) + + answer = @report.answer(question: table_name, cell: 'C2') + answer.add_members(stayers) + answer.update(summary: stayers.count) + + answer = @report.answer(question: table_name, cell: 'C3') + included = stayers.where( + spm_e_t[:current_earned_income].gt(spm_e_t[:previous_earned_income]). + and(spm_e_t[:previous_income_benefits_id].not_eq(nil)), + ) + answer.add_members(included) + answer.update(summary: included.count) + + answer = @report.answer(question: table_name, cell: 'C4') + answer.update(summary: percent(included.count, stayers.count)) + end + + private def run_4_2(table_name) + prepare_table( + table_name, + { + 2 => 'Universe: Number of adults (system stayers)', + 3 => 'Number of adults with increased non-employment cash income', + 4 => 'Percentage of adults who increased non-employment cash income', + }, + COLUMNS, + ) + + answer = @report.answer(question: table_name, cell: 'C2') + answer.add_members(stayers) + answer.update(summary: stayers.count) + + answer = @report.answer(question: table_name, cell: 'C3') + included = stayers.where( + spm_e_t[:current_non_employment_income].gt(spm_e_t[:previous_non_employment_income]). + and(spm_e_t[:previous_income_benefits_id].not_eq(nil)), + ) + answer.add_members(included) + answer.update(summary: included.count) + + answer = @report.answer(question: table_name, cell: 'C4') + answer.update(summary: percent(included.count, stayers.count)) + end + + private def run_4_3(table_name) + prepare_table( + table_name, + { + 2 => 'Universe: Number of adults (system stayers)', + 3 => 'Number of adults with increased total income', + 4 => 'Percentage of adults who increased total income', + }, + COLUMNS, + ) + + answer = @report.answer(question: table_name, cell: 'C2') + answer.add_members(stayers) + answer.update(summary: stayers.count) + + answer = @report.answer(question: table_name, cell: 'C3') + included = stayers.where( + spm_e_t[:current_total_income].gt(spm_e_t[:previous_total_income]). + and(spm_e_t[:previous_income_benefits_id].not_eq(nil)), + ) + answer.add_members(included) + answer.update(summary: included.count) + + answer = @report.answer(question: table_name, cell: 'C4') + answer.update(summary: percent(included.count, stayers.count)) + end + + private def run_4_4(table_name) + prepare_table( + table_name, + { + 2 => 'Universe: Number of adults who exited (system leavers)', + 3 => 'Number of adults who exited with increased earned income', + 4 => 'Percentage of adults who increased earned income', + }, + COLUMNS, + ) + + answer = @report.answer(question: table_name, cell: 'C2') + answer.add_members(leavers) + answer.update(summary: leavers.count) + + answer = @report.answer(question: table_name, cell: 'C3') + included = leavers.where( + spm_e_t[:current_earned_income].gt(spm_e_t[:previous_earned_income]). + and(spm_e_t[:previous_income_benefits_id].not_eq(nil)), + ) + answer.add_members(included) + answer.update(summary: included.count) + + answer = @report.answer(question: table_name, cell: 'C4') + answer.update(summary: percent(included.count, leavers.count)) + end + + private def run_4_5(table_name) + prepare_table( + table_name, + { + 2 => 'Universe: Number of adults who exited (system leavers)', + 3 => 'Number of adults who exited with increased non-employment cash income', + 4 => 'Percentage of adults who increased non-employment cash income', + }, + COLUMNS, + ) + + answer = @report.answer(question: table_name, cell: 'C2') + answer.add_members(leavers) + answer.update(summary: leavers.count) + + answer = @report.answer(question: table_name, cell: 'C3') + included = leavers.where( + spm_e_t[:current_non_employment_income].gt(spm_e_t[:previous_non_employment_income]). + and(spm_e_t[:previous_income_benefits_id].not_eq(nil)), + ) + answer.add_members(included) + answer.update(summary: included.count) + + answer = @report.answer(question: table_name, cell: 'C4') + answer.update(summary: percent(included.count, leavers.count)) + end + + private def run_4_6(table_name) + prepare_table( + table_name, + { + 2 => 'Universe: Number of adults who exited (system leavers)', + 3 => 'Number of adults who exited with increased total income', + 4 => 'Percentage of adults who increased total income', + }, + COLUMNS, + ) + + answer = @report.answer(question: table_name, cell: 'C2') + answer.add_members(leavers) + answer.update(summary: leavers.count) + + answer = @report.answer(question: table_name, cell: 'C3') + included = leavers.where( + spm_e_t[:current_total_income].gt(spm_e_t[:previous_total_income]). + and(spm_e_t[:previous_income_benefits_id].not_eq(nil)), + ) + answer.add_members(included) + answer.update(summary: included.count) + + answer = @report.answer(question: table_name, cell: 'C4') + answer.update(summary: percent(included.count, leavers.count)) + end + + private def candidate_stayers(filter) + enrollment_set.open_during_range(filter.range). + where(spm_e_t[:age].gteq(18)). + where(spm_e_t[:eligible_funding].eq(true)). + where(spm_e_t[:exit_date].eq(nil).or(spm_e_t[:exit_date].gt(filter.end))) + end + + private def stayers + @stayers = @report.universe(:m4_stayers) + return @stayers.members if @stayers_computed + + @stayers_computed = true + + filter = ::Filters::HudFilterBase.new(user_id: @report.user.id).update(@report.options) + staying_enrollments = candidate_stayers(filter).where(spm_e_t[:days_enrolled].gteq(365)) + client_enrollments = HudSpmReport::Fy2024::SpmEnrollment.one_for_column(:entry_date, source_arel_table: spm_e_t, group_on: :client_id, scope: staying_enrollments) + + members = client_enrollments.map do |enrollment| + [enrollment.client, enrollment] + end.to_h + @stayers.add_universe_members(members) + + @stayers.members + end + + private def leavers + @leavers = @report.universe(:m4_leavers) + return @leavers.members if @leavers_computed + + @leavers_computed = true + + filter = ::Filters::HudFilterBase.new(user_id: @report.user.id).update(@report.options) + stayer_ids = candidate_stayers(filter).pluck(:client_id) + + leaving_enrollments = enrollment_set.open_during_range(filter.range). + where(spm_e_t[:age].gteq(18)). + where(spm_e_t[:eligible_funding].eq(true)). + where(spm_e_t[:exit_date].not_eq(nil).and(spm_e_t[:exit_date].lteq(filter.end))). + where.not(client_id: stayer_ids) + client_enrollments = HudSpmReport::Fy2024::SpmEnrollment.one_for_column(:entry_date, source_arel_table: spm_e_t, group_on: :client_id, scope: leaving_enrollments) + + members = client_enrollments.map do |enrollment| + [enrollment.client, enrollment] + end.to_h + @leavers.add_universe_members(members) + + @leavers.members + end + end +end diff --git a/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_one.rb b/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_one.rb new file mode 100644 index 00000000000..c51a3748b85 --- /dev/null +++ b/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_one.rb @@ -0,0 +1,224 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +# HUD SPM Report Generator: Length of Time Persons Remain Homeless +module HudSpmReport::Generators::Fy2024 + class MeasureOne < MeasureBase + def self.question_number + 'Measure 1'.freeze + end + + def self.client_class + HudSpmReport::Fy2024::Episode. + joins(:enrollments).preload(:enrollments) + end + + def self.table_descriptions + { + 'Measure 1' => 'Length of Time Persons Experience Homelessness', + }.freeze + end + + def run_question! + tables = [ + ['1a', :run_1a], + ['1b', :run_1b], + ] + + @report.start(self.class.question_number, tables.map(&:first)) + + tables.each do |name, msg| + send(msg, name) + end + + @report.complete(self.class.question_number) + end + + COLUMNS = { + 'A' => 'Previous FY Universe (Persons)', # leave blank + 'B' => 'Current FY Universe (Persons)', + 'C' => 'Previous FY Average LOT Experiencing Homelessness', # leave blank + 'D' => 'Current FY Average LOT Experiencing Homelessness', + 'E' => 'Difference', # blank + 'F' => 'Previous FY Median LOT Experiencing Homelessness', # leave blank + 'G' => 'Current FY Median LOT Experiencing Homelessness', + 'H' => 'Difference', # leave blank + }.freeze + + private def run_1a(table_name) + prepare_table( + table_name, + { + 1 => 'Persons in ES-EE, ES-NbN, and SH', + 2 => 'Persons in ES-EE, ES-NbN, SH, and TH', + }, + COLUMNS, + external_column_header: true, + external_row_label: true, + ) + + create_universe( + :m1a1, + included_project_types: HudUtility2024.project_type_number_from_code(:es) + HudUtility2024.project_type_number_from_code(:sh), + excluded_project_types: HudUtility2024.project_type_number_from_code(:th) + HudUtility2024.project_type_number_from_code(:ph), + ) + + cell_universe = @report.universe(:m1a1).members + persons, mean, median = compute_row(cell_universe) + answer = @report.answer(question: table_name, cell: :B1) + answer.add_members(cell_universe) + answer.update(summary: persons) + # puts 'M1A B1' + # puts cell_universe.map(&:universe_membership).map(&:client).map(&:personal_id) + # write_detail(answer) + answer = @report.answer(question: table_name, cell: :D1) + answer.add_members(cell_universe) + answer.update(summary: mean) + # write_detail(answer) + answer = @report.answer(question: table_name, cell: :G1) + answer.add_members(cell_universe) + answer.update(summary: median) + # write_detail(answer) + + create_universe( + :m1a2, + included_project_types: HudUtility2024.project_type_number_from_code(:es) + + HudUtility2024.project_type_number_from_code(:sh) + + HudUtility2024.project_type_number_from_code(:th), + excluded_project_types: HudUtility2024.project_type_number_from_code(:ph), + ) + + cell_universe = @report.universe(:m1a2).members + persons, mean, median = compute_row(cell_universe) + answer = @report.answer(question: table_name, cell: :B2) + answer.add_members(cell_universe) + answer.update(summary: persons) + answer = @report.answer(question: table_name, cell: :D2) + answer.add_members(cell_universe) + answer.update(summary: mean) + answer = @report.answer(question: table_name, cell: :G2) + answer.add_members(cell_universe) + answer.update(summary: median) + end + + private def run_1b(table_name) + prepare_table( + table_name, + { + 1 => 'Persons in ES-EE, ES-NbN, SH, and PH', + 2 => 'Persons in ES-EE, ES-NbN, SH, TH, and PH', + }, + COLUMNS, + external_column_header: true, + external_row_label: true, + ) + + create_universe( + :m1b1, + included_project_types: HudUtility2024.project_type_number_from_code(:es) + + HudUtility2024.project_type_number_from_code(:sh), + excluded_project_types: HudUtility2024.project_type_number_from_code(:th) + + HudUtility2024.project_type_number_from_code(:ph), + include_self_reported_and_ph: true, + ) + + cell_universe = @report.universe(:m1b1).members + persons, mean, median = compute_row(cell_universe) + answer = @report.answer(question: table_name, cell: :B1) + answer.add_members(cell_universe) + answer.update(summary: persons) + # puts 'M1B B1' + # puts cell_universe.map(&:universe_membership).map(&:client).map(&:personal_id) + # write_detail(answer) + answer = @report.answer(question: table_name, cell: :D1) + answer.add_members(cell_universe) + answer.update(summary: mean) + answer = @report.answer(question: table_name, cell: :G1) + answer.add_members(cell_universe) + answer.update(summary: median) + + create_universe( + :m1b2, + included_project_types: HudUtility2024.project_type_number_from_code(:es) + + HudUtility2024.project_type_number_from_code(:sh) + + HudUtility2024.project_type_number_from_code(:th), + excluded_project_types: HudUtility2024.project_type_number_from_code(:ph), + include_self_reported_and_ph: true, + ) + + cell_universe = @report.universe(:m1b2).members + persons, mean, median = compute_row(cell_universe) + answer = @report.answer(question: table_name, cell: :B2) + answer.add_members(cell_universe) + answer.update(summary: persons) + # puts 'M1B B2' + # puts cell_universe.map(&:universe_membership).map(&:client).map(&:personal_id) + answer = @report.answer(question: table_name, cell: :D2) + answer.add_members(cell_universe) + answer.update(summary: mean) + answer = @report.answer(question: table_name, cell: :G2) + answer.add_members(cell_universe) + answer.update(summary: median) + end + + private def create_universe(universe_name, included_project_types:, excluded_project_types:, include_self_reported_and_ph: false) + @universe = @report.universe(universe_name) + # Universe + # Measure 1a/Metric 1: Emergency Shelter – Entry Exit (Project Type 0), Emergency Shelter – Night-by-Night (Project Type 1), and Safe Haven (Project Type 8) clients who are active in report date range. + # Measure 1a/Metric 2: Emergency Shelter –Entry Exit (Project Type 0), Emergency Shelter – Night-by-Night (Project Type 1), Safe Haven (Project Type 8), and Transitional Housing (Project Type 2) clients who are active in report date range. + # Measure 1b/Metric 1: Emergency Shelter – Entry Exit (Project Type 0), Emergency Shelter – Night-by-Night (Project Type 1), Safe Haven (Project Type 8), and Permanent Housing (Project Types 3, 9, 10, 13) clients who are active in report date range. For PH projects, only stays meeting the Identifying Clients Experiencing Literal Homelessness at Project Entry criteria are included in time experiencing homelessness. + # Measure 1b/Metric 2: Emergency Shelter – Entry Exit (Project Type 0), Emergency Shelter – Night-by-Night (Project Type 1), Safe Haven (Project Type 8), Transitional Housing (Project Type 2), and Permanent Housing (Project Types 3, 9, 10, 13) clients who are active in report date range. For PH projects, only stays meeting the Identifying Clients Experiencing Literal Homelessness at Project Entry criteria are included in time experiencing homelessness. + candidate_client_ids = enrollment_set. + with_active_method_5_in_range(filter.range). + where(project_type: included_project_types). + pluck(:client_id) + if include_self_reported_and_ph + # For PH projects, only stays meeting the Identifying Clients Experiencing Literal Homelessness at Project Entry criteria are included in time experiencing homelessness + literally_homeless_in_ph = enrollment_set.literally_homeless_at_entry_in_range(filter.range).where(project_type: HudUtility2024.project_type_number_from_code(:ph)) + candidate_client_ids += literally_homeless_in_ph.pluck(:client_id) + end + enrollments = enrollment_set.where(client_id: candidate_client_ids.uniq) + batch_calculator = HudSpmReport::Fy2024::EpisodeBatch.new(enrollments, included_project_types, excluded_project_types, include_self_reported_and_ph, @report) + + client_ids = enrollments.pluck(:client_id).uniq + client_ids.each_slice(500) do |slice| + episodes = batch_calculator.calculate_batch(slice) + next unless episodes.present? + + members = episodes.map do |episode| + [episode.client, episode] + end.to_h + @universe.add_universe_members(members) + end + @universe.members + end + + private def compute_row(universe) + a_t = HudSpmReport::Fy2024::Episode.arel_table + persons = universe.count + return [0, 0, 0] unless persons.positive? + + days_homeless = universe.pluck(a_t[:days_homeless]) + average = mean(days_homeless.sum, persons) + median = median(days_homeless) + + [persons, average, median] + end + + private def mean(num, denom) + format('%1.2f', (num / denom.to_f).round(2)) + end + + private def median(values) + selected = if values.count.even? + (values.count / 2) + 1 + else + values.count / 2 + end + values.sort[selected - 1] # Adjust for 0-based array + end + end +end diff --git a/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_seven.rb b/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_seven.rb new file mode 100644 index 00000000000..36a9877d3d1 --- /dev/null +++ b/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_seven.rb @@ -0,0 +1,242 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +# HUD SPM Report Generator: Measure 2a and 2b: The Extent to which Persons Who Exit Homelessness +# to Permanent Housing Destinations Return to Homelessness within 6, 12, +# and 24 months. +module HudSpmReport::Generators::Fy2024 + class MeasureSeven < MeasureBase + def self.question_number + 'Measure 7'.freeze + end + + def self.table_descriptions + { + 'Measure 7' => 'Successful Placement from Street Outreach and Successful Placement in or Retention of Permanent Housing', + '7a.1' => 'Change in exits to permanent housing destinations', + '7b.1' => 'Change in exits to permanent housing destinations', + '7b.2' => 'Change in exit to or retention of permanent housing', + }.freeze + end + + def run_question! + tables = [ + ['7a.1', :run_7a_1], + ['7b.1', :run_7b_1], + ['7b.2', :run_7b_2], + ] + + @report.start(self.class.question_number, tables.map(&:first)) + + tables.each do |name, msg| + send(msg, name) + end + + @report.complete(self.class.question_number) + end + + COLUMNS = { + 'B' => 'Previous FY', + 'C' => 'Current FY', + 'D' => 'Difference', + }.freeze + + private def run_7a_1(table_name) + prepare_table( + table_name, + { + 2 => 'Universe: Persons who exit Street Outreach', + 3 => 'Of persons above, those who exited to temporary & some institutional destinations', + 4 => 'Of the persons above, those who exited to permanent housing destinations', + 5 => '% Successful exits', + }, + COLUMNS, + ) + + members = create_so_universe + answer = @report.answer(question: table_name, cell: 'C2') + answer.add_members(members) + answer.update(summary: members.count) + + temporary = members.where(spm_e_t[:destination].in(TEMPORARY_AND_INSTITUTIONAL_DESTINATIONS)) + answer = @report.answer(question: table_name, cell: 'C3') + answer.add_members(temporary) + answer.update(summary: temporary.count) + + permanent = members.where(spm_e_t[:destination].in(PERMANENT_DESTINATIONS)) + answer = @report.answer(question: table_name, cell: 'C4') + answer.add_members(permanent) + answer.update(summary: permanent.count) + + answer = @report.answer(question: table_name, cell: 'C5') + answer.update(summary: percent(temporary.count + permanent.count, members.count)) + end + + private def run_7b_1(table_name) + prepare_table( + table_name, + { + 2 => 'Universe: Persons in ES-EE, ES-NbN, SH, TH, and PH-RRH who exited, plus persons in other PH projects who exited without moving into housing', + 3 => 'Of the persons above, those who exited to permanent housing destinations', + 4 => '% Successful exits', + }, + COLUMNS, + ) + + members = create_ph_no_move_in_universe + answer = @report.answer(question: table_name, cell: 'C2') + answer.add_members(members) + answer.update(summary: members.count) + + permanent = members.where(spm_e_t[:destination].in(PERMANENT_DESTINATIONS)) + answer = @report.answer(question: table_name, cell: 'C3') + answer.add_members(permanent) + answer.update(summary: permanent.count) + + answer = @report.answer(question: table_name, cell: 'C4') + answer.update(summary: percent(permanent.count, members.count)) + end + + private def run_7b_2(table_name) + prepare_table( + table_name, + { + 2 => 'Universe: Persons in all PH projects except PH-RRH who exited after moving into housing, or who moved into housing and remained in the PH project', + 3 => 'Of persons above, those who remained in applicable PH projects and those who exited to permanent housing destinations', + 4 => '% Successful exits/retention', + }, + COLUMNS, + ) + + members = create_ph_universe + answer = @report.answer(question: table_name, cell: 'C2') + answer.add_members(members) + answer.update(summary: members.count) + + stayers_and_leavers = members.where( + [ + spm_e_t[:exit_date].lteq(filter.end).and(spm_e_t[:destination].in(PERMANENT_DESTINATIONS)), # leavers + spm_e_t[:exit_date].eq(nil).or(spm_e_t[:exit_date].gt(filter.end)), # stayers + ].inject(&:or), + ) + answer = @report.answer(question: table_name, cell: 'C3') + answer.add_members(stayers_and_leavers) + answer.update(summary: stayers_and_leavers.count) + + answer = @report.answer(question: table_name, cell: 'C4') + answer.update(summary: percent(stayers_and_leavers.count, members.count)) + end + + M7A_REJECTED = [ + 206, # Hospital or other residential non-psychiatric medical facility + 329, # Residential project or halfway house with no homeless criteria + 24, # Deceased + ].freeze + + M7B_REJECTED = [ + 206, # Hospital or other residential non-psychiatric medical facility + 215, # Foster care home or foster care group home + 225, # Long-term care facility or nursing home + 24, # Deceased + ].freeze + + TEMPORARY_AND_INSTITUTIONAL_DESTINATIONS = [ + 101, # Emergency shelter, including hotel or motel paid for with emergency shelter voucher, Host Home shelter + 118, # Safe Haven + + 215, # Foster care home or foster care group home + 204, # Psychiatric hospital or other psychiatric facility + 205, # Substance abuse treatment facility or detox center + 225, # Long-term care facility or nursing home + + 314, # Hotel or motel paid for without emergency shelter voucher + 312, # Staying or living with family, temporary tenure + 313, # Staying or living with friends, temporary tenure + 302, # Transitional housing for homeless persons (including homeless youth) + 327, # Moved from one HOPWA funded project to HOPWA TH + 332, # Host Home (non-crisis) + ].freeze + + PERMANENT_DESTINATIONS = [ + 426, # Moved from one HOPWA funded project to HOPWA PH + 411, # Owned by client, no ongoing housing subsidy + 421, # Owned by client, with ongoing housing subsidy + 410, # Rental by client, no ongoing housing subsidy + 435, # Rental by client, with housing subsidy + 422, # Staying or living with family, permanent tenure + 423, # Staying or living with friends, permanent tenure + ].freeze + + def create_so_universe + universe = @report.universe(:m7a1) + so_enrollments = enrollment_set.open_during_range(filter.range).where(project_type: HudUtility2024.project_type_number_from_code(:so)) + stayers = so_enrollments.where(spm_e_t[:exit_date].eq(nil).or(spm_e_t[:exit_date].gt(filter.end))) + leavers = so_enrollments.where.not(client_id: stayers.select(:client_id)) + enrollments = HudSpmReport::Fy2024::SpmEnrollment.one_for_column(:exit_date, source_arel_table: spm_e_t, group_on: :client_id, scope: leavers) + enrollments = enrollments.where.not(spm_e_t[:destination].in(M7A_REJECTED)) + + members = enrollments.map do |enrollment| + [enrollment.client, enrollment] + end.to_h + universe.add_universe_members(members) + + universe.members + end + + def create_ph_no_move_in_universe + universe = @report.universe(:m7b1) + project_types = [:es, :sh, :th, :ph].flat_map { |code| HudUtility2024.project_type_number_from_code(code) } + open_enrollments = enrollment_set.open_during_range(filter.range).where(project_type: project_types) + stayers = open_enrollments.where(spm_e_t[:exit_date].eq(nil).or(spm_e_t[:exit_date].gt(filter.end))) + leavers = open_enrollments.where.not(client_id: stayers.select(:client_id)) + + # FIXME, should use enrollment ID as a tie break for equal exit_dates + enrollments = HudSpmReport::Fy2024::SpmEnrollment.one_for_column(:exit_date, source_arel_table: spm_e_t, group_on: :client_id, scope: leavers) + ph_not_rrh = HudUtility2024.project_type_number_from_code(:ph) - HudUtility2024.project_type_number_from_code(:rrh) + enrollments = enrollments.where.not(spm_e_t[:project_type].in(ph_not_rrh). + and(spm_e_t[:move_in_date].not_eq(nil).and(spm_e_t[:move_in_date].lteq(filter.end)))) + enrollments = enrollments.where.not(spm_e_t[:destination].in(M7B_REJECTED)) + + members = enrollments.map do |enrollment| + [enrollment.client, enrollment] + end.to_h + universe.add_universe_members(members) + + universe.members + end + + def create_ph_universe + ph_not_rrh = HudUtility2024.project_type_number_from_code(:ph) - HudUtility2024.project_type_number_from_code(:rrh) + open_enrollments = enrollment_set.open_during_range(filter.range).where(project_type: ph_not_rrh) + stayers = open_enrollments.where(spm_e_t[:exit_date].eq(nil).or(spm_e_t[:exit_date].gt(filter.end))) + leavers = open_enrollments.where.not(client_id: stayers.select(:client_id)) + + latest_stays = HudSpmReport::Fy2024::SpmEnrollment.one_for_column(:entry_date, source_arel_table: spm_e_t, group_on: :client_id, scope: stayers) + latest_exits = HudSpmReport::Fy2024::SpmEnrollment.one_for_column(:exit_date, source_arel_table: spm_e_t, group_on: :client_id, scope: leavers) + + # Destinations indicated with an ✗ cause leavers with those destinations to be completely excluded from the entire measure (all of column C). + excluded_ids = leavers.where(spm_e_t[:destination].in(M7B_REJECTED)).pluck(:id) + # if the stay with the latest project start date has no [housing move in date], or the [housing move-in date] is > [report end date], exclude the client completely from this measure. + excluded_ids += latest_stays.where(spm_e_t[:move_in_date].eq(nil).or(spm_e_t[:move_in_date].gt(filter.end))).pluck(:id) + # If there is no [housing move-in date] for that stay, the client is completely excluded from this measure (the client will be reported in 7b.1). + excluded_ids += latest_exits.where(spm_e_t[:move_in_date].eq(nil)).pluck(:id) + + combined_scope = HudSpmReport::Fy2024::SpmEnrollment.where(id: (latest_stays.pluck(:id) + latest_exits.pluck(:id)) - excluded_ids) + + members = combined_scope.map do |enrollment| + [enrollment.client, enrollment] + end.to_h + + universe = @report.universe(:m7b2) + universe.add_universe_members(members) + universe.members + end + + def filter + @filter ||= ::Filters::HudFilterBase.new(user_id: @report.user.id).update(@report.options) + end + end +end diff --git a/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_six.rb b/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_six.rb new file mode 100644 index 00000000000..3d8c41970e3 --- /dev/null +++ b/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_six.rb @@ -0,0 +1,96 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +# HUD SPM Report Generator: Measure 2a and 2b: The Extent to which Persons Who Exit Homelessness +# to Permanent Housing Destinations Return to Homelessness within 6, 12, +# and 24 months. +module HudSpmReport::Generators::Fy2024 + class MeasureSix < MeasureBase + def self.question_number + 'Measure 6'.freeze + end + + def self.table_descriptions + { + 'Measure 6' => "Homeless Prevention and Housing Placement of Persons Defined by Category 3 of HUD's Homeless Definition in CoC Program-funded Projects", + '6a.1 and 6b.1' => 'Returns to ES, SH, TH, and PH projects after exits to permanent housing destinations within 6 and 12 months (and 24 months in a separate calculation)', + '6c.1' => 'Change in exits to permanent housing destinations', + '6c.2' => 'Change in exit to or retention of permanent housing', + }.freeze + end + + def run_question! + tables = [ + ['6a.1 and 6b.1', :run_6a_1], + ['6c.1', :run_6c_1], + ['6c.2', :run_6c_2], + ] + + @report.start(self.class.question_number, tables.map(&:first)) + + tables.each do |name, msg| + send(msg, name) + end + + @report.complete(self.class.question_number) + end + + private def run_6a_1(table_name) + prepare_table( + table_name, + { + 2 => 'Exit was from SO', + 3 => 'Exit was from ES', + 4 => 'Exit was from TH', + 5 => 'Exit was from SH', + 6 => 'Exit was from PH', + 7 => 'TOTAL Returns to Homelessness', + }, + { + 'B' => 'Total Number of Persons who Exited to a Permanent Housing Destination (2 Years Prior)', + 'C' => 'Number Returning to Homelessness in Less than 6 Months (0 - 180 days)', + 'D' => 'Percentage of Returns in Less than 6 Months (0 - 180 days)', + 'E' => 'Number Returning to Homelessness from 6 to 12 Months (181 - 365 days)', + 'F' => 'Percentage of Returns from 6 to 12 Months (181 - 365 days)', + 'G' => 'Number Returning to Homelessness from 13 to 24 Months (366 - 730 days)', + 'H' => 'Percentage of Returns from 13 to 24 Months (366 - 730 days)', + 'I' => 'Number of Returns in 2 Years', + 'J' => 'Percentage of Returns in 2 Years', + }, + ) + end + + COLUMNS = { + 'B' => 'Previous FY', + 'C' => 'Current FY', + 'D' => 'Difference', + }.freeze + + private def run_6c_1(table_name) + prepare_table( + table_name, + { + 2 => 'Universe: Cat. 3 Persons in SH, TH and PH-RRH who exited, plus persons in other PH projects who exited without moving into housing', + 3 => 'Of the persons above, those who exited to permanent destinations', + 4 => '% Successful exits', + }, + COLUMNS, + ) + end + + private def run_6c_2(table_name) + prepare_table( + table_name, + { + 2 => 'Universe: Cat. 3 Persons in all PH projects except PH-RRH who exited after moving into housing, or who moved into housing and remained in the PH project', + 3 => 'Of persons above, count those who remained in PH-PSH projects and those who exited to permanent housing destinations', + 4 => '% Successful exits/retention', + }, + COLUMNS, + ) + end + end +end diff --git a/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_three.rb b/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_three.rb new file mode 100644 index 00000000000..2f558a111fa --- /dev/null +++ b/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_three.rb @@ -0,0 +1,152 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +# HUD SPM Report Generator: Measure 2a and 2b: The Extent to which Persons Who Exit Homelessness +# to Permanent Housing Destinations Return to Homelessness within 6, 12, +# and 24 months. +module HudSpmReport::Generators::Fy2024 + class MeasureThree < MeasureBase + include ArelHelper + + def self.question_number + 'Measure 3'.freeze + end + + def self.table_descriptions + { + 'Measure 3' => 'Number of Persons Experiencing Homelessness', + '3.1' => 'Change in PIT counts of sheltered and unsheltered persons experiencing homelessness', + '3.2' => 'Change in annual counts of persons experiencing sheltered homelessness in HMIS', + }.freeze + end + + def run_question! + tables = [ + ['3.1', :run_3_1], + ['3.2', :run_3_2], + ] + + @report.start(self.class.question_number, tables.map(&:first)) + + tables.each do |name, msg| + send(msg, name) + end + + @report.complete(self.class.question_number) + end + + private def run_3_1(table_name) + # This is a placeholder table that is intended to be populated from the last submitted PIT + prepare_table( + table_name, + { + 2 => 'Universe: Total PIT Count of sheltered and unsheltered persons', + 3 => 'Emergency Shelter Total', + 4 => 'Safe Haven Total', + 5 => 'Transitional Housing Total', + 6 => 'Total Sheltered Count', + 7 => 'Unsheltered Count', + }, + { + 'B' => 'Previous FY PIT Count', + 'C' => 'Current FY PIT Count', + 'D' => 'Difference', + }, + ) + end + + private def run_3_2(table_name) + prepare_table( + table_name, + { + 2 => 'Universe: Unduplicated Total sheltered persons', + 3 => 'Emergency Shelter Total', + 4 => 'Safe Haven Total', + 5 => 'Transitional Housing Total', + }, + { + 'B' => 'Previous FY', + 'C' => 'Current FY', + 'D' => 'Difference', + }, + ) + + build_m3_2_cell( + cell: 'C2', + ee_project_type_codes: [:es_entry_exit, :sh, :th], + nbn_project_type_codes: [:es_nbn], + table_name: table_name, + ) + build_m3_2_cell( + cell: 'C3', + ee_project_type_codes: [:es_entry_exit], + nbn_project_type_codes: [:es_nbn], + table_name: table_name, + ) + build_m3_2_cell( + cell: 'C4', + ee_project_type_codes: [:sh], + table_name: table_name, + ) + build_m3_2_cell( + cell: 'C5', + ee_project_type_codes: [:th], + table_name: table_name, + ) + end + + def project_type_numbers(project_type_codes) + project_type_codes.flat_map do |code| + HudUtility2024.project_type_number_from_code(code) + end + end + + def build_m3_2_cell(cell:, ee_project_type_codes:, table_name:, nbn_project_type_codes: nil) + answer = @report.answer(question: table_name, cell: cell) + + ee_enrollments = enrollment_set.open_during_range(filter.range). + where(project_type: project_type_numbers(ee_project_type_codes)) + + nbn_enrollments = [] + if nbn_project_type_codes.present? + nbn_enrollments = enrollment_set. + with_active_method_2_in_range(filter.range). + where(project_type: project_type_numbers(nbn_project_type_codes)). + where.not(client_id: ee_enrollments.select(:client_id)) + end + + # construct per-cell universe + universe = @report.universe(:"m3_2_#{cell.downcase}") + # add enrollments to universe + [ + ee_enrollments, + nbn_enrollments, + ].each do |spm_enrollments| + next if spm_enrollments.blank? + + uniq_members = HudSpmReport::Fy2024::SpmEnrollment.one_for_column( + :entry_date, + source_arel_table: spm_e_t, + group_on: :client_id, + scope: spm_enrollments, + ) + members = uniq_members.preload(:client).map do |enrollment| + [enrollment.client, enrollment] + end + universe.add_universe_members(members.to_h) + end + + # add universe to cell + answer.add_members(universe.members) + answer.update(summary: universe.members.count) + answer + end + + def filter + ::Filters::HudFilterBase.new(user_id: @report.user.id).update(@report.options) + end + end +end diff --git a/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_two.rb b/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_two.rb new file mode 100644 index 00000000000..69d27b19488 --- /dev/null +++ b/drivers/hud_spm_report/app/models/hud_spm_report/generators/fy2024/measure_two.rb @@ -0,0 +1,155 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +# HUD SPM Report Generator: Measure 2a and 2b: The Extent to which Persons Who Exit Homelessness +# to Permanent Housing Destinations Return to Homelessness within 6, 12, +# and 24 months. +module HudSpmReport::Generators::Fy2024 + class MeasureTwo < MeasureBase + def self.question_number + 'Measure 2'.freeze + end + + def self.client_class + HudSpmReport::Fy2024::Return. + left_outer_joins(:exit_enrollment, :return_enrollment). + preload(exit_enrollment: { enrollment: :project }, return_enrollment: { enrollment: :project }) + end + + def self.table_descriptions + { + 'Measure 2' => 'The Extent to which Persons Who Exit Homelessness to Permanent Housing Destinations Return to Homelessness within 6, 12, and 24 months', + # '2a and 2b' => 'The Extent to which Persons Who Exit Homelessness to Permanent Housing Destinations Return to Homelessness within 6, 12, and 24 months.', + }.freeze + end + + def run_question! + tables = [ + ['2a and 2b', :run_2a_and_b], + ] + + @report.start(self.class.question_number, tables.map(&:first)) + + tables.each do |name, msg| + send(msg, name) + end + + @report.complete(self.class.question_number) + end + + COLUMNS = { + 'B' => 'Total Number of Persons who Exited to a Permanent Housing Destination (2 Years Prior)', + 'C' => 'Number Returning to Homelessness in Less than 6 Months (0 - 180 days)', + 'D' => 'Percentage of Returns in Less than 6 Months (0 - 180 days)', + 'E' => 'Number Returning to Homelessness from 6 to 12 Months (181 - 365 days)', + 'F' => 'Percentage of Returns from 6 to 12 Months (181 - 365 days)', + 'G' => 'Number Returning to Homelessness from 13 to 24 Months (366 - 730 days)', + 'H' => 'Percentage of Returns from 13 to 24 Months (366 - 730 days)', + 'I' => 'Number of Returns in 2 Years', + 'J' => 'Percentage of Returns in 2 Years', + }.freeze + + private def run_2a_and_b(table_name) + prepare_table( + table_name, + { + 2 => 'Exit was from SO', + 3 => 'Exit was from ES', + 4 => 'Exit was from TH', + 5 => 'Exit was from SH', + 6 => 'Exit was from PH', + 7 => 'TOTAL Returns to Homelessness', + }, + COLUMNS, + ) + + members = create_universe(table_name) + totals = { + B: 0, + C: 0, + E: 0, + G: 0, + I: 0, + } + total_answers = { + B: @report.answer(question: table_name, cell: 'B7'), + C: @report.answer(question: table_name, cell: 'C7'), + E: @report.answer(question: table_name, cell: 'E7'), + G: @report.answer(question: table_name, cell: 'G7'), + I: @report.answer(question: table_name, cell: 'I7'), + } + + report_rows.each do |row_number, project_type| + candidates_for_row = members.where(a_t[:project_type].in(project_type)) + answer = @report.answer(question: table_name, cell: 'B' + row_number.to_s) + answer.add_members(candidates_for_row) + total_answers[:B].add_members(candidates_for_row) + row_count = candidates_for_row.count + totals[:B] += row_count + answer.update(summary: row_count) + + report_columns.each do |count_column, (percent_column, query)| + answer = @report.answer(question: table_name, cell: count_column.to_s + row_number.to_s) + included = candidates_for_row.where(query) + answer.add_members(included) + total_answers[count_column].add_members(included) + included_count = included.count + totals[count_column] += included_count + answer.update(summary: included_count) + + answer = @report.answer(question: table_name, cell: percent_column.to_s + row_number.to_s) + answer.update(summary: percent(included_count, row_count)) + end + end + + totals.keys.each do |count_column| + answer = @report.answer(question: table_name, cell: count_column.to_s + '7') + answer.update(summary: totals[count_column]) + + next if count_column == :B # B is the denominator, so don't calculate percentage + + answer = @report.answer(question: table_name, cell: count_column.next.to_s + '7') + answer.update(summary: percent(totals[count_column], totals[:B])) + end + end + + private def a_t + @a_t ||= HudSpmReport::Fy2024::Return.arel_table + end + + private def report_rows + { + 2 => HudUtility2024.project_type_number_from_code(:so), + 3 => HudUtility2024.project_type_number_from_code(:es), + 4 => HudUtility2024.project_type_number_from_code(:th), + 5 => HudUtility2024.project_type_number_from_code(:sh), + 6 => HudUtility2024.project_type_number_from_code(:ph), + }.freeze + end + + private def report_columns + { + # TODO: can days_to_return really be 0, or should it start with 1? + C: [:D, a_t[:days_to_return].between(0..180)], + E: [:F, a_t[:days_to_return].between(181..365)], + G: [:H, a_t[:days_to_return].between(366..730)], + I: [:J, a_t[:days_to_return].between(0..730)], + }.freeze + end + + private def create_universe(table_name) + @universe = @report.universe(table_name) + returns = HudSpmReport::Fy2024::Return.compute_returns(@report, enrollment_set) + + members = returns.map do |enrollment| + [enrollment.client, enrollment] + end.to_h + @universe.add_universe_members(members) + + @universe.members + end + end +end diff --git a/drivers/hud_spm_report/config/initializers/hud_spm_report_feature.rb b/drivers/hud_spm_report/config/initializers/hud_spm_report_feature.rb index e00bf5d87f7..5fe23bc3756 100644 --- a/drivers/hud_spm_report/config/initializers/hud_spm_report_feature.rb +++ b/drivers/hud_spm_report/config/initializers/hud_spm_report_feature.rb @@ -21,3 +21,8 @@ title: 'System Performance Measures', helper: 'hud_reports_spms_path', } + +Rails.application.config.hud_reports['HudSpmReport::Generators::Fy2024::Generator'] = { + title: 'System Performance Measures', + helper: 'hud_reports_spms_path', +} diff --git a/drivers/hud_spm_report/extensions/hud_reports/report_instance_extension.rb b/drivers/hud_spm_report/extensions/hud_reports/report_instance_extension.rb index fa2e58a555e..ac3e233dad2 100644 --- a/drivers/hud_spm_report/extensions/hud_reports/report_instance_extension.rb +++ b/drivers/hud_spm_report/extensions/hud_reports/report_instance_extension.rb @@ -9,7 +9,7 @@ module ReportInstanceExtension extend ActiveSupport::Concern included do - has_many :spm_enrollments, class_name: 'HudSpmReport::Fy2023::SpmEnrollment' + has_many :spm_enrollments, class_name: 'HudSpmReport::Fy2024::SpmEnrollment' end end end diff --git a/drivers/hud_spm_report/extensions/hud_reports/universe_member_extension.rb b/drivers/hud_spm_report/extensions/hud_reports/universe_member_extension.rb index 397d457a98b..ca2325cc704 100644 --- a/drivers/hud_spm_report/extensions/hud_reports/universe_member_extension.rb +++ b/drivers/hud_spm_report/extensions/hud_reports/universe_member_extension.rb @@ -22,9 +22,9 @@ module UniverseMemberExtension belongs_to( :enrollment, -> do - where(HudReports::UniverseMember.arel_table[:universe_membership_type].eq('HudSpmReport::Fy2023::SpmEnrollment')) + where(HudReports::UniverseMember.arel_table[:universe_membership_type].eq('HudSpmReport::Fy2024::SpmEnrollment')) end, - class_name: 'HudSpmReport::Fy2023::SpmEnrollment', + class_name: 'HudSpmReport::Fy2024::SpmEnrollment', foreign_key: :universe_membership_id, inverse_of: :hud_reports_universe_members, optional: true, @@ -32,9 +32,9 @@ module UniverseMemberExtension belongs_to( :return, -> do - where(HudReports::UniverseMember.arel_table[:universe_membership_type].eq('HudSpmReport::Fy2023::Return')) + where(HudReports::UniverseMember.arel_table[:universe_membership_type].eq('HudSpmReport::Fy2024::Return')) end, - class_name: 'HudSpmReport::Fy2023::Return', + class_name: 'HudSpmReport::Fy2024::Return', foreign_key: :universe_membership_id, inverse_of: :hud_reports_universe_members, optional: true, diff --git a/drivers/hud_spm_report/spec/models/datalab_testkit/all_projects_spec.rb b/drivers/hud_spm_report/spec/models/datalab_testkit/all_projects_spec.rb index 7862a4267b4..077252f4993 100644 --- a/drivers/hud_spm_report/spec/models/datalab_testkit/all_projects_spec.rb +++ b/drivers/hud_spm_report/spec/models/datalab_testkit/all_projects_spec.rb @@ -19,7 +19,7 @@ setup puts "Setup Done for SPM Data Lab TestKit #{Time.current}" # run(default_spm_filter, HudSpmReport::Generators::Fy2023::Generator.questions.keys.grep(/Measure 2/)) - run(default_spm_filter, HudSpmReport::Generators::Fy2023::Generator.questions.keys) + run(default_spm_filter, HudSpmReport::Generators::Fy2024::Generator.questions.keys) puts "Finished SPM Run Data Lab TestKit #{Time.current}" end diff --git a/drivers/hud_spm_report/spec/models/datalab_testkit/spm_context.rb b/drivers/hud_spm_report/spec/models/datalab_testkit/spm_context.rb index 595dcc53379..2ed222e48b9 100644 --- a/drivers/hud_spm_report/spec/models/datalab_testkit/spm_context.rb +++ b/drivers/hud_spm_report/spec/models/datalab_testkit/spm_context.rb @@ -27,7 +27,7 @@ def default_spm_filter end def run(filter, question_numbers) - klass = HudSpmReport::Generators::Fy2023::Generator + klass = HudSpmReport::Generators::Fy2024::Generator report = ::HudReports::ReportInstance.from_filter( filter, klass.title, diff --git a/drivers/hud_spm_report/spec/models/fy2024/active_record_preload_spec.rb b/drivers/hud_spm_report/spec/models/fy2024/active_record_preload_spec.rb new file mode 100644 index 00000000000..3236f37fbe8 --- /dev/null +++ b/drivers/hud_spm_report/spec/models/fy2024/active_record_preload_spec.rb @@ -0,0 +1,62 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +require 'rails_helper' + +RSpec.describe 'Active Record Preload API', type: :model do + let!(:warehouse) { create :destination_data_source } + let!(:source_ds) { create :source_data_source } + let!(:warehouse_client) { create :fixed_warehouse_client } + let!(:client_with_enrollments) { warehouse_client.source } + + let!(:enrollments) do + create_list( + :grda_warehouse_hud_enrollment, + 2, + PersonalID: client_with_enrollments.PersonalID, + data_source_id: client_with_enrollments.data_source_id, + ) + end + let!(:services) do + build_list( + :hud_service, + 4, + PersonalID: client_with_enrollments.PersonalID, + EnrollmentID: enrollments.first.EnrollmentID, + data_source_id: client_with_enrollments.data_source_id, + ) do |record, i| + record.DateProvided = '2022-01-01'.to_date + (i * 5.days) + record.save! + end + end + + describe 'preloads work as expected' do + it '4 services are created' do + expect(GrdaWarehouse::Hud::Service.count).to eq(4) + end + it '2 services are between 2022-01-02 and 2022-01-15' do + expect(GrdaWarehouse::Hud::Service.where(DateProvided: '2022-01-02'.to_date .. '2022-01-15'.to_date).count).to eq(2) + end + it 'when preloading, one enrollment has 4 services' do + expect(GrdaWarehouse::Hud::Enrollment.preload(:services).first.services.size).to eq(4) + end + it 'when using includes/references with a scope, only two services are included' do + s_t = GrdaWarehouse::Hud::Service.arel_table + scope = s_t[:DateProvided].eq(nil).or(s_t[:DateProvided].between('2022-01-02'.to_date .. '2022-01-15'.to_date)) + enrollments = GrdaWarehouse::Hud::Enrollment.includes(:services).references(:services).where(scope).to_a + expect(enrollments.first.services.to_a.size).to eq(2) + end + + it 'when preloading with a scope, filters are applied to associations' do + scope = GrdaWarehouse::Hud::Service.where(DateProvided: '2022-01-02'.to_date .. '2022-01-15'.to_date) + expect(GrdaWarehouse::Hud::Enrollment.first.services.size).to eq(4) + enrollments = GrdaWarehouse::Hud::Enrollment.all + ::ActiveRecord::Associations::Preloader.new(records: enrollments, associations: :services, scope: scope).call + enrollments.each { |record| record.public_send(:services) } + expect(enrollments.first.services.to_a.size).to eq(2) + end + end +end diff --git a/drivers/hud_spm_report/spec/models/fy2024/spm_enrollment_context.rb b/drivers/hud_spm_report/spec/models/fy2024/spm_enrollment_context.rb new file mode 100644 index 00000000000..87678580e09 --- /dev/null +++ b/drivers/hud_spm_report/spec/models/fy2024/spm_enrollment_context.rb @@ -0,0 +1,74 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### +# +RSpec.shared_context 'FY2024 SPM enrollment context', shared_context: :metadata do + def default_filter_definition + { + user_id: User.setup_system_user.id, + start: Date.parse('2022-10-1'), + end: Date.parse('2023-09-30'), + coc_codes: ['MA-500'], + }.freeze + end + + def default_filter + ::Filters::HudFilterBase.new(default_filter_definition) + end + + def setup(export_directory) + HmisCsvImporter::Utility.clear! + GrdaWarehouse::Utility.clear! + + # Will use stored fixed point if one exists, instead of reprocessing the fixture, delete the pg_fixture to regenerate + warehouse_fixture = PgFixtures.new( + directory: "drivers/hud_spm_report/spec/fixpoints/#{export_directory}", + excluded_tables: default_excluded_tables, + model: GrdaWarehouseBase, + ) + app_fixture = PgFixtures.new( + directory: "drivers/hud_spm_report/spec/fixpoints/#{export_directory}", + excluded_tables: default_excluded_tables, + model: ApplicationRecord, + ) + if warehouse_fixture.exists? && app_fixture.exists? + puts "Restoring Fixtures #{Time.current}" + warehouse_fixture.restore + app_fixture.restore + puts "Fixtures Restored #{Time.current}" + else + export_path = "drivers/hud_spm_report/spec/fixtures/files/fy2024/#{export_directory}" + import_hmis_csv_fixture(export_path, run_jobs: false) + process_imported_fixtures + warehouse_fixture.store + app_fixture.store + end + end + + def cleanup + GrdaWarehouse::Utility.clear! + end + + def run(filter, question_number) + klass = HudSpmReport::Generators::Fy2024::Generator + @report = HudReports::ReportInstance.from_filter( + filter, + klass.title, + build_for_questions: [question_number], + ) + + @generator = klass.new(@report) + @generator.run! + + @report_result = @generator.report + @report_result.reload + end +end diff --git a/drivers/hud_spm_report/spec/models/fy2024/universe_spec.rb b/drivers/hud_spm_report/spec/models/fy2024/universe_spec.rb new file mode 100644 index 00000000000..ec3f01c3573 --- /dev/null +++ b/drivers/hud_spm_report/spec/models/fy2024/universe_spec.rb @@ -0,0 +1,58 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +require 'rails_helper' +require_relative 'spm_enrollment_context' + +RSpec.describe 'HUD SPM Universe', type: :model do + include_context 'FY2024 SPM enrollment context' + + # Using TestKit instead + # describe 'simple universe' do + # before(:all) do + # setup(:enrollment_universe) + # filter = default_filter.update(project_ids: GrdaWarehouse::Hud::Project.pluck(:id)) + # run(filter, nil) + # HudSpmReport::Fy2024::SpmEnrollment.create_enrollment_set(@report_result) + # end + + # it 'creates enrollments' do + # expect(HudSpmReport::Fy2024::SpmEnrollment.count).to eq(6) + # end + + # it 'creates an episode' do + # client = GrdaWarehouse::Hud::Client.destination.first + # episode = HudSpmReport::Fy2024::Episode.create(report: @report_result, client: client) + # episode.compute_episode( + # HudSpmReport::Fy2024::SpmEnrollment.where(client_id: client.id).to_a, + # included_project_types: [0, 1, 8], + # excluded_project_types: [2], + # include_self_reported_and_ph: true, + # ) + # aggregate_failures do + # expect(episode.first_date).to eq '2022-02-01'.to_date + # expect(episode.last_date).to eq '2022-09-01'.to_date + # end + # end + # end + + # describe 'return universe' do + # before(:all) do + # setup(:return_universe) + # filter = default_filter.update(project_ids: GrdaWarehouse::Hud::Project.pluck(:id)) + # run(filter, nil) + # HudSpmReport::Fy2024::SpmEnrollment.create_enrollment_set(@report_result) + # end + + # it 'computes a return' do + # client = GrdaWarehouse::Hud::Client.destination.first + # enrollments = HudSpmReport::Fy2024::SpmEnrollment.all + # return_to_homelessness = HudSpmReport::Fy2024::Return.new(report_instance: @report_result, client: client).compute_return(enrollments) + + # expect(return_to_homelessness.return_date).to eq '2022-05-01'.to_date + # end + # end +end diff --git a/drivers/override_summary/app/models/override_summary/report.rb b/drivers/override_summary/app/models/override_summary/report.rb index 11d19078c9a..988647fd1df 100644 --- a/drivers/override_summary/app/models/override_summary/report.rb +++ b/drivers/override_summary/app/models/override_summary/report.rb @@ -77,10 +77,12 @@ def override_scope lookups = HmisCsvImporter::ImportOverride.available_classes # Attempt to do as few queries as we can to fetch the projects data_by_file.each do |file_name, d| - # Ignore any overrides to Export.csv, they'll never be related toa project + # Ignore any overrides to Export.csv, they'll never be related to a project next if file_name == 'Export.csv' - # Ignore any overrides to User.csv, they'll never be related toa project + # Ignore any overrides to User.csv, they'll never be related to a project next if file_name == 'User.csv' + # Ignore any overrides to Client.csv, they'll never be related to a project + next if file_name == 'Client.csv' # Throw out any overrides that don't match a specific record (they'll match any project) d.each do |data_source_id, overrides| diff --git a/drivers/performance_measurement/app/models/performance_measurement/report.rb b/drivers/performance_measurement/app/models/performance_measurement/report.rb index 0f9958c4af0..f97ab968c3a 100644 --- a/drivers/performance_measurement/app/models/performance_measurement/report.rb +++ b/drivers/performance_measurement/app/models/performance_measurement/report.rb @@ -275,11 +275,11 @@ def can_see_client_details?(user) private def spm_enrollments_from_answer_member(member) case member - when HudSpmReport::Fy2023::SpmEnrollment + when HudSpmReport::Fy2023::SpmEnrollment, HudSpmReport::Fy2024::SpmEnrollment [member] - when HudSpmReport::Fy2023::Episode + when HudSpmReport::Fy2023::Episode, HudSpmReport::Fy2024::Episode member.enrollments - when HudSpmReport::Fy2023::Return + when HudSpmReport::Fy2023::Return, HudSpmReport::Fy2024::Return [member.exit_enrollment] else raise "unknown type #{member.class.name}" @@ -1063,11 +1063,11 @@ def variants end def spm_fields - default_calculation = ->(spm_enrollment) { spm_enrollment.present? } - days_homeless_calculation = ->(spm_episode) { spm_episode.days_homeless } - destination_calculation = ->(spm_enrollment) { spm_enrollment.destination } - days_to_return_calculation = ->(spm_return) { spm_return.days_to_return } - exit_destination_calculation = ->(spm_return) { spm_return.exit_destination } + default_calculation = lambda(&:present?) + days_homeless_calculation = lambda(&:days_homeless) + destination_calculation = lambda(&:destination) + days_to_return_calculation = lambda(&:days_to_return) + exit_destination_calculation = lambda(&:exit_destination) increased_non_employment_income_calculation = ->(spm_enrollment) { spm_enrollment.current_non_employment_income.to_f > spm_enrollment.previous_non_employment_income.to_f } diff --git a/drivers/performance_metrics/app/models/performance_metrics/report.rb b/drivers/performance_metrics/app/models/performance_metrics/report.rb index 71ca52d183a..746f6d9fa58 100644 --- a/drivers/performance_metrics/app/models/performance_metrics/report.rb +++ b/drivers/performance_metrics/app/models/performance_metrics/report.rb @@ -602,11 +602,11 @@ def report_labels # NOTE: SPM has a 2 year look-back so they may not be in the enrolled clients spm_report = run_spm # M2 B7 is TOTAL Returns to Homeless - Number of Returns in 2 Years - spm_returners = answer_members(spm_report, '2a and 2b', 'I7') # HudSpmReport::Fy2023::Return + spm_returners = answer_members(spm_report, '2a and 2b', 'I7') # HudSpmReport::Fy2024::Return # M2 I7 is Total Number of Persons who Exited to a Permanent Housing Destination (2 Years Prior) - spm_leavers = answer_members(spm_report, '2a and 2b', 'B7') # HudSpmReport::Fy2023::Return + spm_leavers = answer_members(spm_report, '2a and 2b', 'B7') # HudSpmReport::Fy2024::Return # 1A D2 is Average LOT Experiencing Homelessness ES, SH, and TH - spm_episodes = answer_members(spm_report, '1a', 'D2') # HudSpmReport::Fy2023::Episode + spm_episodes = answer_members(spm_report, '1a', 'D2') # HudSpmReport::Fy2024::Episode spm_leavers.each do |client_id, spm_return| days_in_es = nil diff --git a/lib/util/id_protector.rb b/lib/util/id_protector.rb index 6510922bc9c..28e20ca52cb 100644 --- a/lib/util/id_protector.rb +++ b/lib/util/id_protector.rb @@ -15,10 +15,16 @@ def call(env) Rails.application.routes.router.recognize(request) do |route, params| decoded_key = false params.each do |key, value| - if key == :id || key.to_s.ends_with?('_id') + next unless key == :id || key.to_s.ends_with?('_id') + + begin params[key] = ProtectedId::Encoder.decode(value) - decoded_key = true if value != params[key] + rescue OpenSSL::Cipher::CipherError => e + # Suppress Cipher Errors so the response is handled by the controller as an unfound id. + # Still capture the error in Sentry. + Sentry.capture_exception(e) end + decoded_key = true if value != params[key] end if decoded_key env['PATH_INFO'] = route.format(params) diff --git a/spec/requests/admin/users_controller_spec.rb b/spec/requests/admin/users_controller_spec.rb index 68dd41acd13..033be93d00e 100644 --- a/spec/requests/admin/users_controller_spec.rb +++ b/spec/requests/admin/users_controller_spec.rb @@ -47,5 +47,59 @@ expect(updated_user.reload.notify_on_client_added).to be true end end + + context 'when updating user from Role-Based to ACLs' do + let!(:legacy_user) { create :user } + let!(:user_group) { create :user_group } + let!(:role) { create :role } + let!(:access_group) { create :access_group } + + it 'updated user keeps fields' do + user_group.add(legacy_user) + legacy_user.legacy_roles << role + legacy_user.access_groups << access_group + legacy_user.save! + # Ensure the orignal user is using role-based permissions and is assigned the user group + expect(legacy_user.permission_context).to eq('role_based') + expect(legacy_user.user_group_ids).to eq([user_group.id]) + expect(legacy_user.legacy_role_ids).to eq([role.id]) + expect(legacy_user.access_group_ids.include?(access_group.id)).to be true + # The Role-Based user edit form does not include a field for user_group_ids. As a result, it will + # not be included in the parameters sent when switching from Role-Based permissions to ACLs + patch admin_user_path(legacy_user), params: { user: { permission_context: 'acls', access_group_ids: [access_group.id] } } + # Ensure the updated user is using ACLs and is still assigned the user group + expect(legacy_user.reload.permission_context).to eq('acls') + expect(legacy_user.reload.user_group_ids).to eq([user_group.id]) + expect(legacy_user.reload.legacy_role_ids).to eq([role.id]) + expect(legacy_user.reload.access_group_ids.include?(access_group.id)).to be true + end + end + + context 'when updating user from ACLs to Role-Based' do + let!(:acl_user) { create(:acl_user) } + let!(:user_group) { create :user_group } + let!(:role) { create :role } + let!(:access_group) { create :access_group } + + it 'updated user keeps fields' do + user_group.add(acl_user) + acl_user.legacy_roles << role + acl_user.access_groups << access_group + acl_user.save! + # Ensure the orignal user is using role-based permissions and is assigned the user group + expect(acl_user.permission_context).to eq('acls') + expect(acl_user.user_group_ids).to eq([user_group.id]) + expect(acl_user.legacy_role_ids).to eq([role.id]) + expect(acl_user.access_group_ids.include?(access_group.id)).to be true + # The ACL user edit form includes a field for user_group_ids. As a result, it will be + # included in the parameters sent when switching from ACLs to Role-Based permissions + patch admin_user_path(acl_user), params: { user: { permission_context: 'role_based', user_group_ids: [user_group.id] } } + # Ensure the updated user is using ACLs and is still assigned the user group + expect(acl_user.reload.permission_context).to eq('role_based') + expect(acl_user.reload.user_group_ids).to eq([user_group.id]) + expect(acl_user.reload.legacy_role_ids).to eq([role.id]) + expect(acl_user.reload.access_group_ids.include?(access_group.id)).to be true + end + end end end