Skip to content

Commit eab6c94

Browse files
committed
Add ImportSchoolsJob to track imports.
This commit introduces an ImportSchoolsJob type of GoodJob job to allow for enqueueing imports and tracking their asynchronous completion. The controller will return to the caller a Job ID which can be used to track the progress of these jobs and retrieve their results when done. The SchoolImportResult class tracks the job and provides access to the results. There are structured error codes for possible issues in SchoolImportError.
1 parent 20f53e1 commit eab6c94

File tree

8 files changed

+576
-2
lines changed

8 files changed

+576
-2
lines changed

app/jobs/import_schools_job.rb

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# frozen_string_literal: true
2+
3+
class ImportSchoolsJob < ApplicationJob
4+
retry_on StandardError, wait: :polynomially_longer, attempts: 3 do |_job, e|
5+
Sentry.capture_exception(e)
6+
raise e
7+
end
8+
9+
queue_as :import_schools_job
10+
11+
def perform(schools_data:, user_id:, token:)
12+
@results = {
13+
successful: [],
14+
failed: []
15+
}
16+
17+
schools_data.each do |school_data|
18+
import_school(school_data, user_id, token)
19+
end
20+
21+
# Store results in dedicated table
22+
store_results(@results, user_id)
23+
24+
@results
25+
end
26+
27+
private
28+
29+
def store_results(results, user_id)
30+
SchoolImportResult.create!(
31+
job_id: job_id,
32+
user_id: user_id,
33+
results: results
34+
)
35+
rescue StandardError => e
36+
Sentry.capture_exception(e)
37+
# Don't fail the job if we can't store results
38+
end
39+
40+
def import_school(school_data, _user_id, token)
41+
owner = find_owner(school_data[:owner_email], token)
42+
43+
unless owner
44+
@results[:failed] << {
45+
name: school_data[:name],
46+
error_code: SchoolImportError::CODES[:owner_not_found],
47+
error: "Owner not found: #{school_data[:owner_email]}",
48+
owner_email: school_data[:owner_email]
49+
}
50+
return
51+
end
52+
53+
# Check if this owner already has a school as creator
54+
existing_school = School.find_by(creator_id: owner[:id])
55+
if existing_school
56+
@results[:failed] << {
57+
name: school_data[:name],
58+
error_code: SchoolImportError::CODES[:owner_already_creator],
59+
error: "Owner #{school_data[:owner_email]} is already the creator of school '#{existing_school.name}'",
60+
owner_email: school_data[:owner_email],
61+
existing_school_id: existing_school.id
62+
}
63+
return
64+
end
65+
66+
school_params = build_school_params(school_data)
67+
68+
# Use transaction for atomicity
69+
School.transaction do
70+
result = School::Create.call(
71+
school_params: school_params,
72+
creator_id: owner[:id]
73+
)
74+
75+
if result.success?
76+
school = result[:school]
77+
78+
# Auto-verify the imported school
79+
school.verify!
80+
81+
# Create owner role
82+
Role.owner.create!(school_id: school.id, user_id: owner[:id])
83+
84+
@results[:successful] << {
85+
name: school.name,
86+
id: school.id,
87+
code: school.code,
88+
owner_email: school_data[:owner_email]
89+
}
90+
else
91+
@results[:failed] << {
92+
name: school_data[:name],
93+
error_code: SchoolImportError::CODES[:school_validation_failed],
94+
error: format_errors(result[:error]),
95+
owner_email: school_data[:owner_email]
96+
}
97+
end
98+
end
99+
rescue StandardError => e
100+
@results[:failed] << {
101+
name: school_data[:name],
102+
error_code: SchoolImportError::CODES[:unknown_error],
103+
error: e.message,
104+
owner_email: school_data[:owner_email]
105+
}
106+
Sentry.capture_exception(e)
107+
end
108+
109+
def find_owner(email, _token)
110+
return nil if email.blank?
111+
112+
users = UserInfoApiClient.search_by_email(email)
113+
users&.first
114+
rescue StandardError => e
115+
Sentry.capture_exception(e)
116+
nil
117+
end
118+
119+
def build_school_params(school_data)
120+
{
121+
name: school_data[:name],
122+
website: school_data[:website],
123+
address_line_1: school_data[:address_line_1],
124+
address_line_2: school_data[:address_line_2],
125+
municipality: school_data[:municipality],
126+
administrative_area: school_data[:administrative_area],
127+
postal_code: school_data[:postal_code],
128+
country_code: school_data[:country_code].upcase,
129+
reference: school_data[:reference],
130+
creator_agree_authority: true,
131+
creator_agree_terms_and_conditions: true,
132+
creator_agree_responsible_safeguarding: true,
133+
user_origin: 'experience_cs'
134+
}.compact
135+
end
136+
137+
def format_errors(errors)
138+
return errors.to_s unless errors.respond_to?(:full_messages)
139+
140+
errors.full_messages.join(', ')
141+
end
142+
end

app/models/school_import_error.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# frozen_string_literal: true
2+
3+
module SchoolImportError
4+
CODES = {
5+
csv_invalid_format: 'CSV_INVALID_FORMAT',
6+
csv_malformed: 'CSV_MALFORMED',
7+
csv_validation_failed: 'CSV_VALIDATION_FAILED',
8+
owner_not_found: 'OWNER_NOT_FOUND',
9+
owner_already_creator: 'OWNER_ALREADY_CREATOR',
10+
duplicate_owner_email: 'DUPLICATE_OWNER_EMAIL',
11+
school_validation_failed: 'SCHOOL_VALIDATION_FAILED',
12+
job_not_found: 'JOB_NOT_FOUND',
13+
csv_file_required: 'CSV_FILE_REQUIRED',
14+
unknown_error: 'UNKNOWN_ERROR'
15+
}.freeze
16+
17+
class << self
18+
def format_error(code, message, details = {})
19+
{
20+
error_code: CODES[code] || CODES[:unknown_error],
21+
message: message,
22+
details: details
23+
}.compact
24+
end
25+
26+
def format_row_errors(errors_array)
27+
{
28+
error_code: CODES[:csv_validation_failed],
29+
message: 'CSV validation failed',
30+
details: {
31+
row_errors: errors_array
32+
}
33+
}
34+
end
35+
end
36+
end

app/models/school_import_result.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# frozen_string_literal: true
2+
3+
class SchoolImportResult < ApplicationRecord
4+
validates :job_id, presence: true, uniqueness: true
5+
validates :user_id, presence: true
6+
validates :results, presence: true
7+
end

config/initializers/good_job.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,5 @@ def authenticate_admin
1818
# The create_students_job queue is a serial queue that allows only one job at a time.
1919
# DO NOT change the value of create_students_job:1 without understanding the implications
2020
# of processing more than one user creation job at once.
21-
config.good_job.queues = 'create_students_job:1;default:5'
21+
config.good_job.queues = 'create_students_job:1;import_schools_job:1;default:5'
2222
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
class CreateSchoolImportResults < ActiveRecord::Migration[7.1]
4+
def change
5+
create_table :school_import_results, id: :uuid do |t|
6+
t.uuid :job_id, null: false
7+
t.uuid :user_id, null: false
8+
t.jsonb :results, null: false, default: {}
9+
t.timestamps
10+
end
11+
12+
add_index :school_import_results, :job_id, unique: true
13+
add_index :school_import_results, :user_id
14+
end
15+
end

db/schema.rb

Lines changed: 11 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)