Skip to content

Commit c3064ec

Browse files
Bulk student create (Backend) (#443)
closes #442 --------- Co-authored-by: create-issue-branch[bot] <53036503+create-issue-branch[bot]@users.noreply.github.com> Co-authored-by: Dan Halson <[email protected]>
1 parent e9d5529 commit c3064ec

23 files changed

+390
-137
lines changed

.devcontainer/devcontainer.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@
4646
"vscode": {
4747
"extensions": [
4848
"ms-azuretools.vscode-docker",
49-
"ninoseki.vscode-gem-lens",
5049
"eamodio.gitlens",
5150
"github.vscode-pull-request-github",
5251
"wmaurer.change-case",
@@ -57,7 +56,6 @@
5756
"hashicorp.terraform",
5857
"yzhang.markdown-all-in-one",
5958
"mikestead.dotenv",
60-
"wingrunr21.vscode-ruby",
6159
"ms-vscode.remote-repositories",
6260
"github.remotehub",
6361
"circleci.circleci",
@@ -68,7 +66,8 @@
6866
"codezombiech.gitignore",
6967
"shopify.ruby-lsp",
7068
"koichisasada.vscode-rdbg",
71-
"rangav.vscode-thunder-client"
69+
"rangav.vscode-thunder-client",
70+
"ninoseki.vscode-mogami"
7271
],
7372
"settings": {
7473
"terminal.integrated.defaultProfile.linux": "zsh"

.rubocop.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Metrics/BlockLength:
3939
Metrics/AbcSize:
4040
Enabled: false
4141

42-
Metrics/LineLength:
42+
Layout/LineLength:
4343
Enabled: false
4444

4545
Naming/VariableNumber:

Gemfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ gem 'email_validator'
1515
gem 'faraday'
1616
gem 'github_webhook', '~> 1.4'
1717
gem 'globalid'
18-
gem 'good_job', '~> 3.12'
18+
gem 'good_job', '~> 4.3'
1919
gem 'graphql'
2020
gem 'graphql-client'
2121
gem 'image_processing'

Gemfile.lock

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ GEM
162162
email_validator (2.2.4)
163163
activemodel
164164
erubi (1.13.0)
165-
et-orbi (1.2.7)
165+
et-orbi (1.2.11)
166166
tzinfo
167167
factory_bot (6.2.1)
168168
activesupport (>= 5.0.0)
@@ -176,22 +176,22 @@ GEM
176176
ruby2_keywords (>= 0.0.4)
177177
faraday-net_http (3.0.2)
178178
ffi (1.16.3)
179-
fugit (1.8.1)
180-
et-orbi (~> 1, >= 1.2.7)
179+
fugit (1.11.1)
180+
et-orbi (~> 1, >= 1.2.11)
181181
raabro (~> 1.4)
182182
github_webhook (1.4.2)
183183
activesupport (>= 4)
184184
rack (>= 1.3)
185185
railties (>= 4)
186186
globalid (1.1.0)
187187
activesupport (>= 5.0)
188-
good_job (3.29.3)
189-
activejob (>= 6.0.0)
190-
activerecord (>= 6.0.0)
191-
concurrent-ruby (>= 1.0.2)
192-
fugit (>= 1.1)
193-
railties (>= 6.0.0)
194-
thor (>= 0.14.1)
188+
good_job (4.3.0)
189+
activejob (>= 6.1.0)
190+
activerecord (>= 6.1.0)
191+
concurrent-ruby (>= 1.3.1)
192+
fugit (>= 1.11.0)
193+
railties (>= 6.1.0)
194+
thor (>= 1.0.0)
195195
graphiql-rails (1.9.0)
196196
railties
197197
sprockets-rails
@@ -533,7 +533,7 @@ DEPENDENCIES
533533
faraday
534534
github_webhook (~> 1.4)
535535
globalid
536-
good_job (~> 3.12)
536+
good_job (~> 4.3)
537537
graphiql-rails
538538
graphql
539539
graphql-client

app/controllers/api/school_students_controller.rb

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ def index
2020
end
2121

2222
def create
23-
result = SchoolStudent::Create.call(school: @school, school_student_params:, token: current_user.token)
23+
result = SchoolStudent::Create.call(
24+
school: @school, school_student_params:, token: current_user.token
25+
)
2426

2527
if result.success?
2628
head :no_content
@@ -30,7 +32,9 @@ def create
3032
end
3133

3234
def create_batch
33-
result = SchoolStudent::CreateBatch.call(school: @school, uploaded_file: params[:file], token: current_user.token)
35+
result = SchoolStudent::CreateBatch.call(
36+
school: @school, school_students_params:, token: current_user.token, user_id: current_user.id
37+
)
3438

3539
if result.success?
3640
head :no_content
@@ -67,6 +71,16 @@ def school_student_params
6771
params.require(:school_student).permit(:username, :password, :name)
6872
end
6973

74+
def school_students_params
75+
school_students = params.require(:school_students)
76+
77+
school_students.map do |student|
78+
next if student.blank?
79+
80+
student.permit(:username, :password, :name).to_h.with_indifferent_access
81+
end
82+
end
83+
7084
def create_safeguarding_flags
7185
create_teacher_safeguarding_flag
7286
create_owner_safeguarding_flag
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# frozen_string_literal: true
2+
3+
module Api
4+
class UserJobsController < ApiController
5+
before_action :authorize_user
6+
7+
def index
8+
user_jobs = UserJob.where(user_id: current_user.id).includes(:good_job)
9+
jobs = user_jobs.map { |user_job| job_attributes(user_job.good_job) }
10+
if jobs.any?
11+
render json: { jobs: }, status: :ok
12+
else
13+
render json: { error: 'No jobs found' }, status: :not_found
14+
end
15+
end
16+
17+
def show
18+
user_job = UserJob.find_by(job_id: params[:id], teacher_id: current_user.id)
19+
job = job_attributes(user_job.good_job)
20+
if job
21+
render json: { job: }, status: :ok
22+
else
23+
render json: { error: 'Job not found' }, status: :not_found
24+
end
25+
end
26+
27+
private
28+
29+
def job_attributes(job)
30+
{
31+
id: job.id,
32+
concurrency_key: job.concurrency_key,
33+
status: job.status,
34+
scheduled_at: job.scheduled_at,
35+
performed_at: job.performed_at,
36+
finished_at: job.finished_at,
37+
error: job.error
38+
}
39+
end
40+
end
41+
end

app/jobs/create_students_job.rb

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# frozen_string_literal: true
2+
3+
class ConcurrencyExceededForSchool < StandardError; end
4+
5+
class CreateStudentsJob < ApplicationJob
6+
include GoodJob::ActiveJobExtensions::Concurrency
7+
8+
queue_as :default
9+
10+
# Restrict to one job per school to avoid duplicates
11+
good_job_control_concurrency_with(
12+
key: -> { "create_students_job_#{arguments.first[:school_id]}" },
13+
total_limit: 1
14+
)
15+
16+
def self.attempt_perform_later(school_id:, students:, token:, user_id:)
17+
concurrency_key = "create_students_job_#{school_id}"
18+
existing_jobs = GoodJob::Job.where(concurrency_key:, finished_at: nil)
19+
20+
raise ConcurrencyExceededForSchool, 'Only one job per school can be enqueued at a time.' if existing_jobs.exists?
21+
22+
ActiveRecord::Base.transaction do
23+
job = perform_later(school_id:, students:, token:)
24+
UserJob.create!(user_id:, good_job_id: job.job_id) unless job.nil?
25+
26+
job
27+
end
28+
end
29+
30+
def perform(school_id:, students:, token:)
31+
students = Array(students)
32+
33+
responses = ProfileApiClient.create_school_students(token:, students:, school_id:)
34+
return if responses[:created].blank?
35+
36+
responses[:created].each do |user_id|
37+
Role.student.create!(school_id:, user_id:)
38+
end
39+
end
40+
41+
# Don't retry...
42+
rescue_from ConcurrencyExceededForSchool do |e|
43+
Rails.logger.error "Only one job per school can be enqueued at a time: #{school_id}"
44+
Sentry.capture_exception(e)
45+
raise e
46+
end
47+
48+
# Don't retry...
49+
rescue_from ActiveRecord::RecordInvalid do |e|
50+
Rails.logger.error "Failed to create student role: #{e.record.errors.full_messages.join(', ')}"
51+
Sentry.capture_exception(e)
52+
raise e
53+
end
54+
55+
retry_on StandardError, attempts: 3 do |_job, e|
56+
Sentry.capture_exception(e)
57+
raise e
58+
end
59+
end

app/models/user_job.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 UserJob < ApplicationRecord
4+
belongs_to :good_job, class_name: 'GoodJob::Job'
5+
6+
attr_accessor :user
7+
end

config/environments/development.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@
7070
# Highlight code that enqueued background job in logs.
7171
config.active_job.verbose_enqueue_logs = true
7272

73+
# Use the async adapter for Active Job in development
74+
config.active_job.queue_adapter = :good_job
75+
7376
# Suppress logger output for asset requests.
7477
config.assets.quiet = true
7578

config/initializers/awesome_print.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# frozen_string_literal: true
2+
3+
require 'awesome_print' if Rails.env.development?

config/routes.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@
6363
resources :teacher_invitations, param: :token, only: :show do
6464
put :accept, on: :member
6565
end
66+
67+
resources :user_jobs, only: %i[index show]
6668
end
6769

6870
resource :github_webhooks, only: :create, defaults: { formats: :json }
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
class AddJobsFinishedAtToGoodJobBatches < ActiveRecord::Migration[7.1]
4+
def change
5+
reversible do |dir|
6+
dir.up do
7+
# Ensure this incremental update migration is idempotent
8+
# with monolithic install migration.
9+
return if connection.column_exists?(:good_job_batches, :jobs_finished_at)
10+
end
11+
end
12+
13+
change_table :good_job_batches do |t|
14+
t.datetime :jobs_finished_at
15+
end
16+
end
17+
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 CreateGoodJobExecutionDuration < ActiveRecord::Migration[7.1]
4+
def change
5+
reversible do |dir|
6+
dir.up do
7+
# Ensure this incremental update migration is idempotent
8+
# with monolithic install migration.
9+
return if connection.column_exists?(:good_job_executions, :duration)
10+
end
11+
end
12+
13+
add_column :good_job_executions, :duration, :interval
14+
end
15+
end
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
class AddConcurrencyKeyToGoodJobExecutions < ActiveRecord::Migration[6.0]
2+
def change
3+
add_column :good_job_executions, :concurrency_key, :string
4+
add_index :good_job_executions, :concurrency_key
5+
end
6+
end
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
class CreateUserJobs < ActiveRecord::Migration[7.1]
2+
def change
3+
create_table :user_jobs, id: :uuid do |t|
4+
t.uuid :user_id, null: false, type: :uuid
5+
t.uuid :good_job_id, null: false, type: :uuid
6+
7+
t.timestamps
8+
end
9+
10+
add_foreign_key :user_jobs, :good_jobs, column: :good_job_id
11+
add_index :user_jobs, [:user_id, :good_job_id], unique: true
12+
end
13+
end

db/schema.rb

Lines changed: 14 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)