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 @@ - -
- + +

Assignments last synced at diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index 64d7032b..404639ad 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -29,7 +29,13 @@