Skip to content

Commit 07de033

Browse files
committed
Add a new Administrate page to allow and track uploads.
This commit adds new UI in /admin to allow users to upload CSVs of schools and track the results of the upload jobs in the UI. They can also download CSV files of the resulting school structures. Add index, show and new views for Administrate Add SchoolImportResultDashboard Add routes for admin interface for school_import_results Add SchoolImportResultsController to show results in the UI. This commit also adds code to new.html.erb to show validation errors if the uploaded CSV can't be enqueued as a batch.
1 parent a629f0a commit 07de033

File tree

16 files changed

+904
-0
lines changed

16 files changed

+904
-0
lines changed
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# frozen_string_literal: true
2+
3+
require 'csv'
4+
5+
module Admin
6+
class SchoolImportResultsController < Admin::ApplicationController
7+
def index
8+
search_term = params[:search].to_s.strip
9+
resources = Administrate::Search.new(
10+
SchoolImportResult.all,
11+
dashboard_class,
12+
search_term
13+
).run
14+
resources = apply_collection_includes(resources)
15+
resources = order.apply(resources)
16+
resources = resources.page(params[:_page]).per(records_per_page)
17+
18+
# Batch load user info to avoid N+1 queries
19+
user_ids = resources.map(&:user_id).compact.uniq
20+
RequestStore.store[:user_info_cache] = fetch_users_batch(user_ids)
21+
22+
page = Administrate::Page::Collection.new(dashboard, order: order)
23+
24+
render locals: {
25+
resources: resources,
26+
search_term: search_term,
27+
page: page,
28+
show_search_bar: show_search_bar?
29+
}
30+
end
31+
32+
def show
33+
respond_to do |format|
34+
format.html do
35+
render locals: {
36+
page: Administrate::Page::Show.new(dashboard, requested_resource)
37+
}
38+
end
39+
format.csv do
40+
send_data generate_csv(requested_resource),
41+
filename: "school_import_#{requested_resource.job_id}_#{Date.current.strftime('%Y-%m-%d')}.csv",
42+
type: 'text/csv'
43+
end
44+
end
45+
end
46+
47+
def new
48+
@error_details = flash[:error_details]
49+
render locals: {
50+
page: Administrate::Page::Form.new(dashboard, SchoolImportResult.new)
51+
}
52+
end
53+
54+
def create
55+
if params[:csv_file].blank?
56+
flash[:error] = 'CSV file is required'
57+
redirect_to new_admin_school_import_result_path
58+
return
59+
end
60+
61+
# Call the same service that the API endpoint uses, ensuring all validation is applied
62+
result = School::ImportBatch.call(
63+
csv_file: params[:csv_file],
64+
current_user: current_user
65+
)
66+
67+
if result.success?
68+
flash[:notice] = "Import job started successfully. Job ID: #{result[:job_id]}"
69+
redirect_to admin_school_import_results_path
70+
else
71+
# Display error inline on the page
72+
flash.now[:error] = format_error_message(result[:error])
73+
@error_details = extract_error_details(result[:error])
74+
render :new, locals: {
75+
page: Administrate::Page::Form.new(dashboard, SchoolImportResult.new)
76+
}
77+
end
78+
end
79+
80+
private
81+
82+
def default_sorting_attribute
83+
:created_at
84+
end
85+
86+
def default_sorting_direction
87+
:desc
88+
end
89+
90+
def format_error_message(error)
91+
return error.to_s unless error.is_a?(Hash)
92+
93+
error[:message] || error['message'] || 'Import failed'
94+
end
95+
96+
def extract_error_details(error)
97+
return nil unless error.is_a?(Hash)
98+
99+
error[:details] || error['details']
100+
end
101+
102+
def generate_csv(import_result)
103+
104+
CSV.generate(headers: true) do |csv|
105+
# Header row
106+
csv << ['Status', 'School Name', 'School Code', 'School ID', 'Owner Email', 'Error Code', 'Error Message']
107+
108+
results = import_result.results
109+
successful = results['successful'] || []
110+
failed = results['failed'] || []
111+
112+
# Successful schools
113+
successful.each do |school|
114+
csv << [
115+
'Success',
116+
school['name'],
117+
school['code'],
118+
school['id'],
119+
school['owner_email'],
120+
'',
121+
''
122+
]
123+
end
124+
125+
# Failed schools
126+
failed.each do |school|
127+
csv << [
128+
'Failed',
129+
school['name'],
130+
'',
131+
'',
132+
school['owner_email'],
133+
school['error_code'],
134+
school['error']
135+
]
136+
end
137+
end
138+
end
139+
140+
def fetch_users_batch(user_ids)
141+
return {} if user_ids.empty?
142+
143+
users = UserInfoApiClient.fetch_by_ids(user_ids)
144+
users.each_with_object({}) do |user, hash|
145+
hash[user[:id]] = user
146+
end
147+
rescue StandardError => e
148+
Rails.logger.error("Failed to batch fetch user info: #{e.message}")
149+
{}
150+
end
151+
end
152+
end
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# frozen_string_literal: true
2+
3+
require 'administrate/base_dashboard'
4+
5+
class SchoolImportResultDashboard < Administrate::BaseDashboard
6+
ATTRIBUTE_TYPES = {
7+
id: Field::String,
8+
job_id: StatusField,
9+
user_id: UserInfoField,
10+
results: ResultsSummaryField,
11+
created_at: Field::DateTime,
12+
updated_at: Field::DateTime
13+
}.freeze
14+
15+
COLLECTION_ATTRIBUTES = %i[
16+
job_id
17+
user_id
18+
results
19+
created_at
20+
].freeze
21+
22+
SHOW_PAGE_ATTRIBUTES = %i[
23+
id
24+
job_id
25+
user_id
26+
results
27+
created_at
28+
updated_at
29+
].freeze
30+
31+
FORM_ATTRIBUTES = [].freeze
32+
33+
COLLECTION_FILTERS = {}.freeze
34+
35+
def display_resource(school_import_result)
36+
"Import Job #{school_import_result.job_id}"
37+
end
38+
end
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
require 'administrate/field/base'
4+
5+
class ResultsSummaryField < Administrate::Field::Base
6+
def to_s
7+
"#{successful_count} successful, #{failed_count} failed"
8+
end
9+
10+
def successful_count
11+
data.dig('successful')&.count || 0
12+
end
13+
14+
def failed_count
15+
data.dig('failed')&.count || 0
16+
end
17+
18+
def total_count
19+
successful_count + failed_count
20+
end
21+
end

app/fields/status_field.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# frozen_string_literal: true
2+
3+
require 'administrate/field/base'
4+
5+
class StatusField < Administrate::Field::Base
6+
def to_s
7+
job_status
8+
end
9+
10+
def job_status
11+
return 'unknown' if data.blank?
12+
13+
job = GoodJob::Execution.find_by(active_job_id: data)
14+
return 'not_found' unless job
15+
16+
return 'failed' if job.error.present?
17+
return 'completed' if job.finished_at.present?
18+
return 'scheduled' if job.scheduled_at.present? && job.scheduled_at > Time.current
19+
return 'running' if job.performed_at.present? && job.finished_at.nil?
20+
21+
'queued'
22+
end
23+
24+
def status_class
25+
case job_status
26+
when 'completed' then 'status-completed'
27+
when 'failed' then 'status-failed'
28+
when 'running' then 'status-running'
29+
when 'queued', 'scheduled' then 'status-queued'
30+
else 'status-unknown'
31+
end
32+
end
33+
end

app/fields/user_info_field.rb

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# frozen_string_literal: true
2+
3+
require 'administrate/field/base'
4+
5+
class UserInfoField < Administrate::Field::Base
6+
def to_s
7+
user_display
8+
end
9+
10+
def user_display
11+
return 'Unknown User' if data.blank?
12+
13+
user_info = fetch_user_info
14+
return data if user_info.nil?
15+
16+
if user_info[:name].present? && user_info[:email].present?
17+
"#{user_info[:name]} <#{user_info[:email]}>"
18+
elsif user_info[:name].present?
19+
user_info[:name]
20+
elsif user_info[:email].present?
21+
user_info[:email]
22+
else
23+
data
24+
end
25+
end
26+
27+
def user_name
28+
return nil if data.blank?
29+
30+
user_info = fetch_user_info
31+
user_info&.dig(:name)
32+
end
33+
34+
def user_email
35+
return nil if data.blank?
36+
37+
user_info = fetch_user_info
38+
user_info&.dig(:email)
39+
end
40+
41+
def user_id
42+
data
43+
end
44+
45+
private
46+
47+
def fetch_user_info
48+
return @user_info if defined?(@user_info)
49+
50+
@user_info = begin
51+
# Try to get from request-level cache first (set by controller)
52+
cache = RequestStore.store[:user_info_cache] || {}
53+
cached = cache[data]
54+
55+
if cached
56+
cached
57+
else
58+
# Fallback to individual API call if not in cache
59+
result = UserInfoApiClient.fetch_by_ids([data])
60+
result&.first
61+
end
62+
rescue StandardError => e
63+
Rails.logger.error("Failed to fetch user info for #{data}: #{e.message}")
64+
nil
65+
end
66+
end
67+
end
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<%#
2+
# Index
3+
4+
This view is the template for the index page.
5+
It renders the search bar, header and pagination.
6+
It renders the `_table` partial to display details about the resources.
7+
8+
## Local variables:
9+
10+
- `resources`:
11+
An ActiveModel::Relation collection of resources to be displayed in the table.
12+
By default, the number of resources is limited by pagination
13+
or by a hard limit to prevent excessive page load times
14+
%>
15+
16+
<% content_for(:title) { "School Import History" } %>
17+
18+
<header class="main-content__header">
19+
<h1 class="main-content__page-title">
20+
<%= content_for(:title) %>
21+
</h1>
22+
23+
<div>
24+
<%= link_to(
25+
"New Import",
26+
new_admin_school_import_result_path,
27+
class: "button button--primary",
28+
) %>
29+
</div>
30+
</header>
31+
32+
<section class="main-content__body main-content__body--flush">
33+
<%= render(
34+
"collection",
35+
collection_presenter: page,
36+
collection_field_name: :resources,
37+
page: page,
38+
resources: resources,
39+
table_title: "page_title"
40+
) %>
41+
42+
<%= paginate resources, param_name: :_page %>
43+
</section>

0 commit comments

Comments
 (0)