Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cd077d5
Add CanCan ability and routes for import schools endpoints.
fspeirs Nov 21, 2025
8be3b25
Update UserInfoApiClient to allow user lookup by email.
fspeirs Nov 20, 2025
de3afa3
Add SchoolImportJob to track imports.
fspeirs Dec 1, 2025
1baf9fb
Add School::ImportBatch operation to import a CSV of schools.
fspeirs Dec 2, 2025
b2d5ece
Update/Add controllers for school import and job tracking.
fspeirs Nov 20, 2025
d8115ad
Test coverage for CSV school import.
fspeirs Nov 20, 2025
2e7d32b
Add documentation - CSM guide and example CSV template
fspeirs Nov 20, 2025
3e834a2
Add a new Administrate page to allow and track uploads.
fspeirs Nov 27, 2025
bfe0994
Change from GoodJob::Execution to GoodJob::Job for upload tracking.
fspeirs Dec 2, 2025
ba75b7c
Number of small Rubocop fixings and refactorings.
fspeirs Dec 2, 2025
4ace5d7
Modify SchoolImportJobsController to use jbuilder for JSON response.
fspeirs Dec 4, 2025
103e430
Modify SchoolsController to use jbuilder
fspeirs Dec 4, 2025
f24adda
Remove redundant reinitialisation of OperationResponse.
fspeirs Dec 4, 2025
1ab74dd
Make the array of required school CSV fields a constant.
fspeirs Dec 4, 2025
dbcaa1f
Modify check on owner to fail if owner has any role in any other school.
fspeirs Dec 4, 2025
f182293
Consolidate school import abilities into one method
fspeirs Dec 4, 2025
62710ab
Refactor SchoolImportJob to use SchoolVerificationService directly in…
fspeirs Dec 4, 2025
a3b577d
Rename find_owner method to find_user_account_for_proposed_owner
fspeirs Dec 4, 2025
2d4bb6c
Refactor the stubbed_users methods out of UserInfoApiClient; refactor…
fspeirs Dec 4, 2025
dfa6b03
Update path to CSV template file.
fspeirs Dec 4, 2025
5f26d02
Retain BYPASS_OAUTH funcationlity but defer to stubs.
fspeirs Dec 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions app/controllers/admin/school_import_results_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# frozen_string_literal: true

require 'csv'

module Admin
class SchoolImportResultsController < Admin::ApplicationController
def index
search_term = params[:search].to_s.strip
resources = Administrate::Search.new(
SchoolImportResult.all,
dashboard_class,
search_term
).run
resources = apply_collection_includes(resources)
resources = order.apply(resources)
resources = resources.page(params[:_page]).per(records_per_page)

# Batch load user info to avoid N+1 queries
user_ids = resources.filter_map(&:user_id).uniq
RequestStore.store[:user_info_cache] = fetch_users_batch(user_ids)

page = Administrate::Page::Collection.new(dashboard, order: order)

render locals: {
resources: resources,
search_term: search_term,
page: page,
show_search_bar: show_search_bar?
}
end

def show
respond_to do |format|
format.html do
render locals: {
page: Administrate::Page::Show.new(dashboard, requested_resource)
}
end
format.csv do
send_data generate_csv(requested_resource),
filename: "school_import_#{requested_resource.job_id}_#{Date.current.strftime('%Y-%m-%d')}.csv",
type: 'text/csv'
end
end
end

def new
@error_details = flash[:error_details]
render locals: {
page: Administrate::Page::Form.new(dashboard, SchoolImportResult.new)
}
end

def create
if params[:csv_file].blank?
flash[:error] = I18n.t('errors.admin.csv_file_required')
redirect_to new_admin_school_import_result_path
return
end

# Call the same service that the API endpoint uses, ensuring all validation is applied
result = School::ImportBatch.call(
csv_file: params[:csv_file],
current_user: current_user
)

if result.success?
flash[:notice] = "Import job started successfully. Job ID: #{result[:job_id]}"
redirect_to admin_school_import_results_path
else
# Display error inline on the page
flash.now[:error] = format_error_message(result[:error])
@error_details = extract_error_details(result[:error])
render :new, locals: {
page: Administrate::Page::Form.new(dashboard, SchoolImportResult.new)
}
end
end

private

def default_sorting_attribute
:created_at
end

def default_sorting_direction
:desc
end

def format_error_message(error)
return error.to_s unless error.is_a?(Hash)

error[:message] || error['message'] || 'Import failed'
end

def extract_error_details(error)
return nil unless error.is_a?(Hash)

error[:details] || error['details']
end

def generate_csv(import_result)
CSV.generate(headers: true) do |csv|
# Header row
csv << ['Status', 'School Name', 'School Code', 'School ID', 'Owner Email', 'Error Code', 'Error Message']

results = import_result.results
successful = results['successful'] || []
failed = results['failed'] || []

# Successful schools
successful.each do |school|
csv << [
'Success',
school['name'],
school['code'],
school['id'],
school['owner_email'],
'',
''
]
end

# Failed schools
failed.each do |school|
csv << [
'Failed',
school['name'],
'',
'',
school['owner_email'],
school['error_code'],
school['error']
]
end
end
end

def fetch_users_batch(user_ids)
return {} if user_ids.empty?

users = UserInfoApiClient.fetch_by_ids(user_ids)
return {} if users.nil?

users.index_by { |user| user[:id] }
rescue StandardError => e
Rails.logger.error("Failed to batch fetch user info: #{e.message}")
{}
end
end
end
50 changes: 50 additions & 0 deletions app/controllers/api/school_import_jobs_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# frozen_string_literal: true

module Api
class SchoolImportJobsController < ApiController
before_action :authorize_user

def show
authorize! :read, :school_import_job
@job = find_job

if @job.nil?
render json: SchoolImportError.format_error(:job_not_found, 'Job not found'), status: :not_found
return
end

@status = job_status(@job)
@result = SchoolImportResult.find_by(job_id: @job.active_job_id) if @job.succeeded?
render :show, formats: [:json], status: :ok
end

private

def find_job
job = GoodJob::Job.find_by(active_job_id: params[:id])

# Verify this is an import job (security check)
return nil unless job && job.job_class == SchoolImportJob.name

job
end

def job_status(job)
return 'discarded' if job.discarded?
return 'succeeded' if job.succeeded?
return 'failed' if job_failed?(job)
return 'running' if job.running?
return 'scheduled' if job_scheduled?(job)

'queued'
end

def job_failed?(job)
job.finished? && job.error.present?
end

def job_scheduled?(job)
job.scheduled_at.present? && job.scheduled_at > Time.current
end
end
end
24 changes: 24 additions & 0 deletions app/controllers/api/schools_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ module Api
class SchoolsController < ApiController
before_action :authorize_user
load_and_authorize_resource
skip_load_and_authorize_resource only: :import

def index
@schools = School.accessible_by(current_ability)
Expand Down Expand Up @@ -47,6 +48,29 @@ def destroy
end
end

def import
authorize! :import, School

if params[:csv_file].blank?
render json: { error: SchoolImportError.format_error(:csv_file_required, 'CSV file is required') },
status: :unprocessable_entity
return
end

result = School::ImportBatch.call(
csv_file: params[:csv_file],
current_user: current_user
)

if result.success?
@job_id = result[:job_id]
@total_schools = result[:total_schools]
render :import, formats: [:json], status: :accepted
else
render json: { error: result[:error] }, status: :unprocessable_entity
end
end

private

def school_params
Expand Down
38 changes: 38 additions & 0 deletions app/dashboards/school_import_result_dashboard.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# frozen_string_literal: true

require 'administrate/base_dashboard'

class SchoolImportResultDashboard < Administrate::BaseDashboard
ATTRIBUTE_TYPES = {
id: Field::String,
job_id: StatusField,
user_id: UserInfoField,
results: ResultsSummaryField,
created_at: Field::DateTime,
updated_at: Field::DateTime
}.freeze

COLLECTION_ATTRIBUTES = %i[
job_id
user_id
results
created_at
].freeze

SHOW_PAGE_ATTRIBUTES = %i[
id
job_id
user_id
results
created_at
updated_at
].freeze

FORM_ATTRIBUTES = [].freeze

COLLECTION_FILTERS = {}.freeze

def display_resource(school_import_result)
"Import Job #{school_import_result.job_id}"
end
end
21 changes: 21 additions & 0 deletions app/fields/results_summary_field.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

require 'administrate/field/base'

class ResultsSummaryField < Administrate::Field::Base
def to_s
"#{successful_count} successful, #{failed_count} failed"
end

def successful_count
data['successful']&.count || 0
end

def failed_count
data['failed']&.count || 0
end

def total_count
successful_count + failed_count
end
end
44 changes: 44 additions & 0 deletions app/fields/status_field.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

require 'administrate/field/base'

class StatusField < Administrate::Field::Base
def to_s
job_status
end

def job_status
return 'unknown' if data.blank?

job = GoodJob::Job.find_by(active_job_id: data)
return 'not_found' unless job

determine_job_status(job)
end

def status_class
case job_status
when 'succeeded', 'completed' then 'status-completed'
when 'failed', 'discarded' then 'status-failed'
when 'running' then 'status-running'
when 'queued', 'scheduled' then 'status-queued'
else 'status-unknown'
end
end

private

def determine_job_status(job)
return 'discarded' if job.discarded?
return 'succeeded' if job.succeeded?
return 'failed' if job.finished? && job.error.present?
return 'running' if job.running?
return 'scheduled' if scheduled?(job)

'queued'
end

def scheduled?(job)
job.scheduled_at.present? && job.scheduled_at > Time.current
end
end
Loading