diff --git a/Procfile b/Procfile
new file mode 100644
index 00000000..3d320f82
--- /dev/null
+++ b/Procfile
@@ -0,0 +1,7 @@
+# GoodJob runs in-process inside Puma (config.good_job.execution_mode = :async
+# in config/environments/production.rb), so no separate `worker` process is
+# needed for the single-instance-per-environment setup.
+#
+# To run a dedicated worker instead, set execution_mode to :external and add:
+# worker: bundle exec good_job start
+web: bundle exec rails server -p $PORT
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index b1ff17d5..d4cb69c5 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -85,8 +85,6 @@ $secondary: $california-gold;
@import "custom_bootstrap";
@import "font-awesome";
-@import "datatables/dataTables.bootstrap5.css";
-@import "datatables/responsive.bootstrap5.css";
@each $name, $value in $colors {
.bg-#{$name} {
diff --git a/app/controllers/courses_controller.rb b/app/controllers/courses_controller.rb
index a5e408f1..4dc3c83b 100644
--- a/app/controllers/courses_controller.rb
+++ b/app/controllers/courses_controller.rb
@@ -1,5 +1,5 @@
class CoursesController < ApplicationController
- 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 bulk_update_assignments enrollments delete]
# Currently exclude routes that expect JSON.
before_action :require_course_staff!, only: %i[edit enrollments delete]
before_action :set_pending_request_count
@@ -77,6 +77,17 @@ def sync_assignments
render json: { message: 'Assignments synced successfully.' }, status: :ok
end
+ def bulk_update_assignments
+ return render json: { error: 'Course not found.' }, status: :not_found unless @course
+ return render json: { error: 'You do not have permission.' }, status: :forbidden unless @course.staff_user?(current_user)
+
+ enabled = ActiveModel::Type::Boolean.new.cast(params[:enabled])
+ scope = Assignment.where(course_to_lms_id: CourseToLms.where(course_id: @course.id).select(:id))
+ scope = scope.where.not(due_date: nil) if enabled
+ scope.update_all(enabled: enabled) # rubocop:disable Rails/SkipsModelValidations
+ render json: { success: true }, status: :ok
+ end
+
def sync_enrollments
return render json: { error: 'Course not found.' }, status: :not_found unless @course
return render json: { error: 'You do not have permission.' }, status: :forbidden unless @course.staff_user?(current_user)
@@ -85,6 +96,18 @@ def sync_enrollments
render json: { message: 'Users synced successfully.' }, status: :ok
end
+ def sync_status
+ return render json: { error: 'You do not have permission.' }, status: :forbidden unless @course.staff_user?(current_user)
+
+ course_to_lms = @course.course_to_lms
+ 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'
@enrollments = @course.enrollments.includes(:user)
diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb
index 7d6a98ff..8188f9d0 100644
--- a/app/controllers/session_controller.rb
+++ b/app/controllers/session_controller.rb
@@ -191,14 +191,14 @@ def update_user_credential(user, token)
user.lms_credentials.first.update(
token: token.token,
refresh_token: token.refresh_token,
- expire_time: Time.zone.at(token.expires_at)
+ expire_time: Time.zone.at(token.expires_at || 30.days.from_now.to_i)
)
else
user.lms_credentials.create!(
lms_id: Lms.CANVAS_LMS.id,
token: token.token,
refresh_token: token.refresh_token,
- expire_time: Time.zone.at(token.expires_at)
+ expire_time: Time.zone.at(token.expires_at || 30.days.from_now.to_i)
)
end
end
diff --git a/app/javascript/controllers/assignment_controller.js b/app/javascript/controllers/assignment_controller.js
index f65c0faa..3f3688f9 100644
--- a/app/javascript/controllers/assignment_controller.js
+++ b/app/javascript/controllers/assignment_controller.js
@@ -1,12 +1,13 @@
import { Controller } from "@hotwired/stimulus"
import DataTable from "datatables.net-bs5";
+import { pollUntilDone } from "controllers/sync_poller";
import "datatables.net-responsive";
import "datatables.net-responsive-bs5";
// Connects to data-controller="assignment"
export default class extends Controller {
- static targets = ["checkbox"]
- static values = { courseId: Number }
+ static targets = ["checkbox", "syncBtn", "syncLabel", "syncSpinner"]
+ static values = { courseId: Number, bulkUrl: String }
connect() {
if (!DataTable.isDataTable('#assignments-table')) {
@@ -59,31 +60,84 @@ export default class extends Controller {
}
}
- sync(event) {
+ async bulkUpdate(enabled) {
+ const url = this.bulkUrlValue;
+ const token = document.querySelector('meta[name="csrf-token"]').content;
+
+ try {
+ const response = await fetch(url, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRF-Token": token,
+ },
+ body: JSON.stringify({ enabled }),
+ });
+
+ if (!response.ok) throw new Error("Failed to update assignments.");
+
+ const dt = DataTable.isDataTable('#assignments-table')
+ ? new DataTable('#assignments-table')
+ : null;
+ if (dt) {
+ dt.rows().nodes().each((node) => {
+ const cb = node.querySelector('.assignment-enabled-switch');
+ if (cb) cb.checked = enabled;
+ });
+ } else {
+ document.querySelectorAll(".assignment-enabled-switch").forEach((cb) => {
+ cb.checked = enabled;
+ });
+ }
+ } catch (error) {
+ flash("alert", error.message || "An error occurred.");
+ }
+ }
+
+ enableAll(event) {
const button = event.currentTarget;
button.disabled = true;
+ this.bulkUpdate(true).finally(() => { button.disabled = false; });
+ }
+
+ disableAll(event) {
+ const button = event.currentTarget;
+ button.disabled = true;
+ this.bulkUpdate(false).finally(() => { button.disabled = false; });
+ }
+
+ 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 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");
+ }
}
+
}
diff --git a/app/javascript/controllers/enrollments_controller.js b/app/javascript/controllers/enrollments_controller.js
index d216cf49..0ad64f83 100644
--- a/app/javascript/controllers/enrollments_controller.js
+++ b/app/javascript/controllers/enrollments_controller.js
@@ -1,10 +1,11 @@
import { Controller } from "@hotwired/stimulus";
import DataTable from "datatables.net-bs5";
+import { pollUntilDone } from "controllers/sync_poller";
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() {
@@ -76,30 +77,39 @@ 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}`);
+
+ await 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");
+ }
+ }
+
}
diff --git a/app/javascript/controllers/sync_poller.js b/app/javascript/controllers/sync_poller.js
new file mode 100644
index 00000000..0a561169
--- /dev/null
+++ b/app/javascript/controllers/sync_poller.js
@@ -0,0 +1,11 @@
+export async function 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 r = await fetch(`/courses/${courseId}/sync_status`);
+ if (!r.ok) throw new Error(`Sync status check failed. ${r.status}`);
+ const status = await r.json();
+ if (status[key] && status[key] !== beforeTs) return;
+ }
+ throw new Error("Sync timed out. Please refresh the page.");
+}
diff --git a/app/models/course.rb b/app/models/course.rb
index 2f47b647..6a15b4d0 100644
--- a/app/models/course.rb
+++ b/app/models/course.rb
@@ -268,14 +268,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 Enrollment 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)
diff --git a/app/views/courses/enrollments.html.erb b/app/views/courses/enrollments.html.erb
index 637e5a84..b88e42ea 100644
--- a/app/views/courses/enrollments.html.erb
+++ b/app/views/courses/enrollments.html.erb
@@ -81,18 +81,22 @@
-
-
-
- Enrollments last synced at
- <%= @enrollments_last_synced_at ? @enrollments_last_synced_at.strftime('%a, %b %-d at %-I:%M%P') : 'never' %>
-
-
+ <% if @course.staff_user?(current_user) %>
+
+
+
+ Enrollments last synced at
+ <%= @enrollments_last_synced_at ? @enrollments_last_synced_at.strftime('%a, %b %-d at %-I:%M%P') : 'never' %>
+
+
+ <% end %>
diff --git a/app/views/courses/instructor_show.html.erb b/app/views/courses/instructor_show.html.erb
index 0285ab11..948f827a 100644
--- a/app/views/courses/instructor_show.html.erb
+++ b/app/views/courses/instructor_show.html.erb
@@ -48,13 +48,25 @@
-
-
-