From 2068758ae427e7484ceef0e9a1b1a1711a603bef Mon Sep 17 00:00:00 2001 From: rebuilt Date: Wed, 11 Jun 2025 13:54:56 -0700 Subject: [PATCH] ECP-170 Remove login requirement for Trition. Switch to using predefined passwords stored in the database for district login. --- app/controllers/sqm_application_controller.rb | 4 +- app/lib/seeder.rb | 4 ++ app/models/district.rb | 1 + app/services/credentials_loader.rb | 45 +++++++++++++++++++ app/services/sftp/file.rb | 17 +++++++ ...50611181510_add_credentials_to_district.rb | 7 +++ ..._and_qualtrics_code_indexes_to_district.rb | 7 +++ db/schema.rb | 8 +++- db/seeds.rb | 4 ++ .../controllers/categories_controller_spec.rb | 2 +- spec/controllers/overview_controller_spec.rb | 6 ++- spec/fixtures/sample_district_credentials.csv | 4 ++ spec/services/credentials_loader_spec.rb | 37 +++++++++++++++ spec/support/basic_auth_helper.rb | 4 +- spec/system/sqm_application_spec.rb | 12 +---- 15 files changed, 146 insertions(+), 16 deletions(-) create mode 100644 app/services/credentials_loader.rb create mode 100644 app/services/sftp/file.rb create mode 100644 db/migrate/20250611181510_add_credentials_to_district.rb create mode 100644 db/migrate/20250611182208_add_name_slug_and_qualtrics_code_indexes_to_district.rb create mode 100644 spec/fixtures/sample_district_credentials.csv create mode 100644 spec/services/credentials_loader_spec.rb diff --git a/app/controllers/sqm_application_controller.rb b/app/controllers/sqm_application_controller.rb index eca24b37..d1b1d2f2 100644 --- a/app/controllers/sqm_application_controller.rb +++ b/app/controllers/sqm_application_controller.rb @@ -10,7 +10,7 @@ class SqmApplicationController < ApplicationController private def authenticate_district - authenticate(district_name, "#{district_name}!") + authenticate(@district.username, @district.password) end def district_name @@ -35,6 +35,8 @@ class SqmApplicationController < ApplicationController end def authenticate(username, password) + return unless @district.login_required + authenticate_or_request_with_http_basic do |u, p| u == username && p == password end diff --git a/app/lib/seeder.rb b/app/lib/seeder.rb index 8a298412..a8423ef6 100644 --- a/app/lib/seeder.rb +++ b/app/lib/seeder.rb @@ -147,6 +147,10 @@ class Seeder EspLoader.load_data(filepath: esp_file) end + def seed_district_credentials(file:) + CredentialsLoader.load_credentials(file:) + end + private def value_from(pattern:, row:) matches = row.headers.select do |header| diff --git a/app/models/district.rb b/app/models/district.rb index 47c58629..b33eca55 100644 --- a/app/models/district.rb +++ b/app/models/district.rb @@ -2,6 +2,7 @@ class District < ApplicationRecord has_many :schools + encrypts :password validates :name, presence: true diff --git a/app/services/credentials_loader.rb b/app/services/credentials_loader.rb new file mode 100644 index 00000000..e0c5a689 --- /dev/null +++ b/app/services/credentials_loader.rb @@ -0,0 +1,45 @@ +require "csv" + +class CredentialsLoader + def self.load_credentials(file:) + credentials = [] + CSV.parse(file, headers: true) do |row| + values = CredentialRowValues.new(row:) + next unless values.district.present? + + credentials << values.district + end + District.import(credentials, batch_size: 100, on_duplicate_key_update: [:username, :password, :login_required]) + end +end + +class CredentialRowValues + attr_reader :row + + def initialize(row:) + @row = row + end + + def district + @district ||= begin + name = row["Districts"]&.strip + district = District.find_or_initialize_by(name:) + district.username = username + district.password = password + district.login_required = login_required? + district + end + end + + def username + row["Username"]&.strip + end + + def password + row["PW"]&.strip + end + + def login_required? + row["Login Required"]&.strip == "Y" + end +end diff --git a/app/services/sftp/file.rb b/app/services/sftp/file.rb new file mode 100644 index 00000000..7ad4ced6 --- /dev/null +++ b/app/services/sftp/file.rb @@ -0,0 +1,17 @@ +require 'net/sftp' +require 'uri' + +module Sftp + class File + def self.open(filepath:, &block) + sftp_url = ENV['SFTP_URL'] + uri = URI.parse(sftp_url) + Net::SFTP.start(uri.host, uri.user, password: uri.password) do |sftp| + sftp.file.open(filepath, 'r', &block) + end + rescue Net::SFTP::StatusException => e + puts "Error opening file: #{e.message}" + nil + end + end +end diff --git a/db/migrate/20250611181510_add_credentials_to_district.rb b/db/migrate/20250611181510_add_credentials_to_district.rb new file mode 100644 index 00000000..a9ded0c8 --- /dev/null +++ b/db/migrate/20250611181510_add_credentials_to_district.rb @@ -0,0 +1,7 @@ +class AddCredentialsToDistrict < ActiveRecord::Migration[8.0] + def change + add_column :districts, :username, :string, null: true, default: nil + add_column :districts, :password, :string, null: true, default: nil + add_column :districts, :login_required, :boolean, null: false, default: true + end +end diff --git a/db/migrate/20250611182208_add_name_slug_and_qualtrics_code_indexes_to_district.rb b/db/migrate/20250611182208_add_name_slug_and_qualtrics_code_indexes_to_district.rb new file mode 100644 index 00000000..61dfa022 --- /dev/null +++ b/db/migrate/20250611182208_add_name_slug_and_qualtrics_code_indexes_to_district.rb @@ -0,0 +1,7 @@ +class AddNameSlugAndQualtricsCodeIndexesToDistrict < ActiveRecord::Migration[8.0] + def change + add_index :districts, :name, unique: true + add_index :districts, :slug, unique: true + add_index :districts, :qualtrics_code, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 8d0351f3..d690e3f8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_05_23_222834) do +ActiveRecord::Schema[8.0].define(version: 2025_06_11_182208) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -69,6 +69,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_23_222834) do t.integer "qualtrics_code" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "username" + t.string "password" + t.boolean "login_required", default: true, null: false + t.index ["name"], name: "index_districts_on_name", unique: true + t.index ["qualtrics_code"], name: "index_districts_on_qualtrics_code", unique: true + t.index ["slug"], name: "index_districts_on_slug", unique: true end create_table "ells", force: :cascade do |t| diff --git a/db/seeds.rb b/db/seeds.rb index 5c331a00..b5711b75 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -14,3 +14,7 @@ seeder.seed_staffing Rails.root.join("data", "staffing", "staffing.csv") seeder.seed_staffing Rails.root.join("data", "staffing", "nj_staffing.csv") seeder.seed_staffing Rails.root.join("data", "staffing", "wi_staffing.csv") seeder.seed_esp_counts Rails.root.join("data", "staffing", "esp_counts.csv") + +Sftp::File.open(filepath: "/ecp/district_credentials.csv") do |file| + seeder.seed_district_credentials file: +end diff --git a/spec/controllers/categories_controller_spec.rb b/spec/controllers/categories_controller_spec.rb index 8d01c559..5a531778 100644 --- a/spec/controllers/categories_controller_spec.rb +++ b/spec/controllers/categories_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe CategoriesController, type: :controller do include BasicAuthHelper let(:school) { create(:school) } - let(:district) { create(:district) } + let(:district) { create(:district, username: 'maynard', password: 'maynard!', login_required: true) } let!(:categories) do [create(:category, name: 'Second', sort_index: 2), create(:category, name: 'First', sort_index: 1)] end diff --git a/spec/controllers/overview_controller_spec.rb b/spec/controllers/overview_controller_spec.rb index 577ca11d..4375025b 100644 --- a/spec/controllers/overview_controller_spec.rb +++ b/spec/controllers/overview_controller_spec.rb @@ -4,11 +4,15 @@ include VarianceHelper describe OverviewController, type: :controller do include BasicAuthHelper let(:school) { create(:school) } - let(:district) { create(:district) } + let(:district) { create(:district, username: 'maynard', password: 'maynard!', login_required: true) } let!(:categories) do [create(:category, name: 'Second', sort_index: 2), create(:category, name: 'First', sort_index: 1)] end + before do + district + end + it 'fetches categories sorted by sort_index' do login_as district get :index, params: { school_id: school.to_param, district_id: district.to_param } diff --git a/spec/fixtures/sample_district_credentials.csv b/spec/fixtures/sample_district_credentials.csv new file mode 100644 index 00000000..bb38b025 --- /dev/null +++ b/spec/fixtures/sample_district_credentials.csv @@ -0,0 +1,4 @@ +Districts,Login Required,Username,PW +Maynard Public Schools,Y,maynard_admin,password123 +Springfield Public Schools,N,springfield_admin,password456 +Boston Public Schools,Y,boston_admin,password789 diff --git a/spec/services/credentials_loader_spec.rb b/spec/services/credentials_loader_spec.rb new file mode 100644 index 00000000..d0bb167b --- /dev/null +++ b/spec/services/credentials_loader_spec.rb @@ -0,0 +1,37 @@ +require "rails_helper" +require "fileutils" + +RSpec.describe CredentialsLoader do + let(:path) do + Rails.root.join("spec", "fixtures", "credentials", "credentials.csv") + end + + context ".load_credentials" do + before do + create(:district, name: "Maynard Public Schools") + create(:district, name: "Springfield Public Schools") + create(:district, name: "Boston Public Schools") + end + + it "loads credentials from the CSV file into the database" do + file = File.open(Rails.root.join("spec", "fixtures", "sample_district_credentials.csv")) + # Seeder.new.seed_district_credentials(file:) + expect { CredentialsLoader.load_credentials(file:) }.to change { District.count }.by(0) + + district = District.find_by(name: "Maynard Public Schools") + expect(district.username).to eq("maynard_admin") + expect(district.password).to eq("password123") + expect(district.login_required).to be true + + district = District.find_by(name: "Springfield Public Schools") + expect(district.username).to eq("springfield_admin") + expect(district.password).to eq("password456") + expect(district.login_required).to be false + + district = District.find_by(name: "Boston Public Schools") + expect(district.username).to eq("boston_admin") + expect(district.password).to eq("password789") + expect(district.login_required).to be true + end + end +end diff --git a/spec/support/basic_auth_helper.rb b/spec/support/basic_auth_helper.rb index a2eaf1a6..6f896c31 100644 --- a/spec/support/basic_auth_helper.rb +++ b/spec/support/basic_auth_helper.rb @@ -1,7 +1,7 @@ module BasicAuthHelper def login_as(district) - user = district.short_name - pw = "#{user}!" + user = district.username + pw = district.password request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(user, pw) end end diff --git a/spec/system/sqm_application_spec.rb b/spec/system/sqm_application_spec.rb index 824700f1..c98ef4ae 100644 --- a/spec/system/sqm_application_spec.rb +++ b/spec/system/sqm_application_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" describe "SQM Application" do - let(:district) { create(:district) } + let(:district) { create(:district, username: 'maynard', password: 'maynard!', login_required: true) } let(:school) { create(:school, district:) } let(:academic_year) { create(:academic_year) } let(:category) { create(:category) } @@ -11,7 +11,7 @@ describe "SQM Application" do before :each do driven_by :rack_test - page.driver.browser.basic_authorize(username, password) + page.driver.browser.basic_authorize(district.username, district.password) create(:respondent, school:, academic_year:) ResponseRate.create!(subcategory:, school:, academic_year:, student_response_rate: 0, teacher_response_rate: 0, meets_student_threshold: false, meets_teacher_threshold: false) @@ -46,14 +46,6 @@ describe "SQM Application" do private - def username - district.short_name - end - - def password - "#{username}!" - end - def overview_path district_school_overview_index_path(district, school, year: academic_year.range) end