Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ gem 'tzinfo-data', platforms: %i[windows jruby]
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false

# Loads environment variables from .env (local dev/test only, not Heroku)
gem 'dotenv-rails', groups: [ :development, :test ]

# Alternative Canvas API. We probably don't need this.
# Verify instances of `LMS::Canvas`
gem 'lms-api'
Expand Down Expand Up @@ -70,6 +73,7 @@ gem 'lograge'
#
gem 'blazer'
gem 'hypershield'
gem 'good_job', '~> 4.0'

#### Frontend related tools
# The original asset pipeline for Rails [https://github.com/rails/sprockets-rails]
Expand Down
19 changes: 19 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,16 @@ GEM
diff-lcs (1.6.2)
docile (1.4.1)
domain_name (0.6.20240107)
dotenv (3.2.0)
dotenv-rails (3.2.0)
dotenv (= 3.2.0)
railties (>= 6.1)
drb (2.2.3)
dumb_delegator (1.1.0)
erb (6.0.2)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
factory_bot (6.5.6)
activesupport (>= 6.1.0)
factory_bot_rails (6.5.1)
Expand All @@ -215,8 +221,18 @@ GEM
sassc (~> 2.0)
formatador (1.2.3)
reline
fugit (1.12.1)
et-orbi (~> 1.4)
raabro (~> 1.4)
globalid (1.3.0)
activesupport (>= 6.1)
good_job (4.14.2)
activejob (>= 6.1.0)
activerecord (>= 6.1.0)
concurrent-ruby (>= 1.3.1)
fugit (>= 1.11.0)
railties (>= 6.1.0)
thor (>= 1.0.0)
guard (2.20.1)
formatador (>= 0.2.4)
listen (>= 2.7, < 4.0)
Expand Down Expand Up @@ -382,6 +398,7 @@ GEM
public_suffix (7.0.5)
puma (7.2.0)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.6)
rack-protection (4.2.1)
Expand Down Expand Up @@ -624,10 +641,12 @@ DEPENDENCIES
cucumber-rails
database_cleaner-active_record
debug
dotenv-rails
factory_bot_rails
faraday
faraday-cookie_jar
font-awesome-sass
good_job (~> 4.0)
guard-rspec
hypershield
importmap-rails
Expand Down
2 changes: 2 additions & 0 deletions Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
web: bundle exec rails server -p $PORT
worker: bundle exec good_job start
12 changes: 11 additions & 1 deletion app/controllers/courses_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
class CoursesController < ApplicationController
before_action :authenticate_user
before_action :set_course, only: %i[show edit sync_assignments sync_enrollments enrollments delete]
before_action :set_course, only: %i[show edit sync_assignments sync_enrollments sync_status enrollments delete]
before_action :set_pending_request_count
before_action :determine_user_role

Expand Down Expand Up @@ -89,6 +89,16 @@ def sync_enrollments
render json: { message: 'Users synced successfully.' }, status: :ok
end

def sync_status
course_to_lms = @course.course_to_lms(1)
return render json: { error: 'LMS connection not found.' }, status: :not_found unless course_to_lms

render json: {
roster_synced_at: course_to_lms.recent_roster_sync&.dig('synced_at'),
assignments_synced_at: course_to_lms.recent_assignment_sync&.dig('synced_at')
}, status: :ok
end

def enrollments
@side_nav = 'enrollments'
return redirect_to courses_path, alert: 'You do not have access to this page.' unless @role == 'instructor'
Expand Down
64 changes: 40 additions & 24 deletions app/javascript/controllers/assignment_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import "datatables.net-responsive-bs5";

// Connects to data-controller="assignment"
export default class extends Controller {
static targets = ["checkbox"]
static targets = ["checkbox", "syncBtn", "syncLabel", "syncSpinner"]
static values = { courseId: Number }

connect() {
Expand Down Expand Up @@ -64,31 +64,47 @@ export default class extends Controller {
}
}

sync(event) {
const button = event.currentTarget;
button.disabled = true;
async sync() {
const button = this.syncBtnTarget;
const label = this.syncLabelTarget;
const spinner = this.syncSpinnerTarget;
const courseId = this.courseIdValue;
const token = document.querySelector('meta[name="csrf-token"]').content;
fetch(`/courses/${courseId}/sync_assignments`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": token,
},
})
.then((response) => {
if (!response.ok) {
throw new Error("Failed to sync assignments.");
}
return response.json();
})
.then((data) => {
flash("notice", data.message || "Assignments synced successfully.");
location.reload();
})
.catch((error) => {
flash("alert", error.message || "An error occurred while syncing assignments.");
location.reload();

button.disabled = true;
label.textContent = "Syncing...";
spinner.classList.remove("d-none");

try {
const statusBefore = await fetch(`/courses/${courseId}/sync_status`).then(r => r.json());
const beforeTs = statusBefore.assignments_synced_at;

const response = await fetch(`/courses/${courseId}/sync_assignments`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": token },
});

if (!response.ok) throw new Error(`Failed to sync assignments. ${response.status}`);

await this._pollUntilDone(courseId, "assignments_synced_at", beforeTs);

flash("notice", "Assignments synced successfully.");
location.reload();
} catch (error) {
flash("alert", error.message || "An error occurred while syncing assignments.");
button.disabled = false;
label.textContent = "Sync Assignments";
spinner.classList.add("d-none");
}
}

async _pollUntilDone(courseId, key, beforeTs, intervalMs = 1000, timeoutMs = 60000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, intervalMs));
const status = await fetch(`/courses/${courseId}/sync_status`).then(r => r.json());
if (status[key] && status[key] !== beforeTs) return;
}
throw new Error("Sync timed out. Please refresh the page.");
}
}
67 changes: 43 additions & 24 deletions app/javascript/controllers/enrollments_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import "datatables.net-responsive";
import "datatables.net-responsive-bs5";

export default class extends Controller {
static targets = ["checkbox"]
static targets = ["checkbox", "syncBtn", "syncLabel", "syncSpinner"]
static values = { courseId: Number }

connect() {
Expand Down Expand Up @@ -76,30 +76,49 @@ export default class extends Controller {
window.dispatchEvent(new CustomEvent('flash', { detail: { type: type, message: message } }));
}

sync() {
const button = event.currentTarget;
button.disabled = true;
async sync() {
const button = this.syncBtnTarget;
const label = this.syncLabelTarget;
const spinner = this.syncSpinnerTarget;
const courseId = this.courseIdValue;
const token = document.querySelector('meta[name="csrf-token"]').content; fetch(`/courses/${courseId}/sync_enrollments`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": token,
},
})
.then((response) => {
if (!response.ok) {
throw new Error(`Failed to sync enrollments. ${response.status} - ${response.statusText}`);
}
return response.json();
})
.then((data) => {
flash("notice", data.message || "Enrollments synced successfully.");
const token = document.querySelector('meta[name="csrf-token"]')?.content || '';

button.disabled = true;
label.textContent = "Syncing...";
spinner.classList.remove("d-none");

try {
// Capture timestamp before sync so we can detect when job finishes
const statusBefore = await fetch(`/courses/${courseId}/sync_status`).then(r => r.json());
const beforeTs = statusBefore.roster_synced_at;

const response = await fetch(`/courses/${courseId}/sync_enrollments`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": token },
});

if (!response.ok) throw new Error(`Failed to sync enrollments. ${response.status}`);

// Poll until synced_at changes
await this._pollUntilDone(courseId, "roster_synced_at", beforeTs);

flash("notice", "Enrollments synced successfully.");
location.reload();
})
.catch((error) => {
} catch (error) {
flash("alert", error.message || "An error occurred while syncing enrollments.");
location.reload();
});
}
button.disabled = false;
label.textContent = "Sync Enrollments";
spinner.classList.add("d-none");
}
}

async _pollUntilDone(courseId, key, beforeTs, intervalMs = 1000, timeoutMs = 60000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, intervalMs));
const status = await fetch(`/courses/${courseId}/sync_status`).then(r => r.json());
if (status[key] && status[key] !== beforeTs) return;
}
throw new Error("Sync timed out. Please refresh the page.");
}
}
4 changes: 2 additions & 2 deletions app/models/course.rb
Original file line number Diff line number Diff line change
Expand Up @@ -246,14 +246,14 @@ def sync_assignments(sync_user)
return unless lms_links.any?

lms_links.each do |course_to_lms|
SyncAllCourseAssignmentsJob.perform_now(course_to_lms.id, sync_user.id)
SyncAllCourseAssignmentsJob.perform_later(course_to_lms.id, sync_user.id)
end
end

# Fetch users for a course and create/find their User and UserToCourse records
# TODO: This may need to become a background job
def sync_users_from_canvas(user, roles = [ 'student' ])
SyncUsersFromCanvasJob.perform_now(id, user, roles)
SyncUsersFromCanvasJob.perform_later(id, user, roles)
end

def sync_all_enrollments_from_canvas(user)
Expand Down
6 changes: 4 additions & 2 deletions app/views/courses/enrollments.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,10 @@
<button class="btn btn-info"
data-controller="enrollments"
data-action="click->enrollments#sync"
data-enrollments-course-id-value="<%= @course.id %>">
Sync Enrollments
data-enrollments-course-id-value="<%= @course.id %>"
data-enrollments-target="syncBtn">
<span data-enrollments-target="syncLabel">Sync Enrollments</span>
<span data-enrollments-target="syncSpinner" class="spinner-border spinner-border-sm ms-1 d-none" role="status" aria-hidden="true"></span>
</button>
</div>
<% end %>
Expand Down
6 changes: 4 additions & 2 deletions app/views/courses/instructor_show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,10 @@
<button class="btn btn-info"
data-controller="assignment"
data-action="click->assignment#sync"
data-assignment-course-id-value="<%= @course.id %>">
Sync Assignments
data-assignment-course-id-value="<%= @course.id %>"
data-assignment-target="syncBtn">
<span data-assignment-target="syncLabel">Sync Assignments</span>
<span data-assignment-target="syncSpinner" class="spinner-border spinner-border-sm ms-1 d-none" role="status" aria-hidden="true"></span>
</button>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class Application < Rails::Application
config.active_record.default_timezone = :utc
config.time_zone = 'Pacific Time (US & Canada)'
config.generators.system_tests = nil
config.active_job.queue_adapter = :good_job

# We do not require the master key and insetad use environment variables
# Review .env.example for required variables.
Expand Down
5 changes: 5 additions & 0 deletions config/environments/test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@
# Raise error when a before_action's only/except options reference missing actions
config.action_controller.raise_on_missing_callback_actions = true

# Disable GoodJob's in-process worker so enqueued jobs do not execute during tests.
# Without this, GoodJob runs jobs in background threads, causing sync operations to
# complete before Capybara can observe transient UI states (spinner, disabled button).
config.good_job.execution_mode = :external

# Set up default encryption keys for the test environment
config.active_record.encryption.primary_key = 'test-primary-key-1234567890abcdef'
config.active_record.encryption.deterministic_key = 'test-deterministic-key-1234567890abcdef'
Expand Down
17 changes: 17 additions & 0 deletions config/initializers/good_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Rails.application.config.to_prepare do
GoodJob::ApplicationController.class_eval do
def current_user
@current_user ||= User.find_by(canvas_uid: session[:user_id])
end

before_action :require_admin

def require_admin
if current_user.nil?
redirect_to '/', alert: 'You must be logged in.'
elsif !current_user.admin?
redirect_to '/', alert: 'You are not authorized to view this page.'
end
end
end
end
2 changes: 1 addition & 1 deletion config/initializers/lograge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
config.lograge.custom_payload do |controller|
{
request_id: controller.request.uuid,
user_id: controller.current_user.try(:id)
user_id: controller.is_a?(ApplicationController) ? controller.current_user.try(:id) : nil
}
end

Expand Down
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
member do
post :sync_assignments
post :sync_enrollments
get :sync_status
get :enrollments
delete :delete
end
Expand Down Expand Up @@ -68,4 +69,5 @@

# This is protected by `require_admin` via blazer.yml
mount Blazer::Engine, at: "admin/blazer"
mount GoodJob::Engine, at: "admin/good_job"
end
Loading
Loading