Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
af2a3d3
Async sync enrollments and sync assignments, added UI to indicate pen…
gobears01 Apr 8, 2026
556182c
Added dotenv gem to run app locally
gobears01 Apr 8, 2026
f6a4434
Add Procfile for Heroku web and worker dynos
gobears01 Apr 8, 2026
4a5bf8c
Added more RSpec and cucumber tests
gobears01 Apr 9, 2026
e807177
Add RSpec and Cucumber tests for async syncs
gobears01 Apr 10, 2026
7701fab
Fixed Ruby lint
gobears01 Apr 10, 2026
17a375f
Changed polling time to 1s
gobears01 Apr 14, 2026
25d7799
Added initializer for good_job
gobears01 Apr 20, 2026
5396841
scoping lograge current_user to app controllers
gobears01 Apr 20, 2026
f416d98
Retrigger CI
gobears01 Apr 20, 2026
071c68b
Fix undefined _fetchJson call in assignment_controller sync
gobears01 Apr 20, 2026
805d9c8
Resolved merge conflict sync_assignment method
gobears01 Apr 24, 2026
258aeea
Merge branch 'main' into gem-dashboards
gobears01 Apr 24, 2026
3a1010e
Fix rubocop: remove extra empty line at block body end
gobears01 Apr 24, 2026
5f972b8
Merge pull request #349 from cs169/late_days_column
gobears01 Apr 30, 2026
0665f6a
Trigger CI
gobears01 Apr 30, 2026
6ef3916
Trigger CI
gobears01 Apr 30, 2026
b88a6f6
Merge branch 'main' into gem-dashboards
gobears01 Apr 30, 2026
897bb9a
Fix course spec to use perform_later
gobears01 Apr 30, 2026
8fe182a
added 2 buttons to allow all assignments to be enabled or disables
Shklalom Apr 25, 2026
065812c
Fixed Ruby lint and replaced updateAll method in bulkAssignmentUpdate…
gobears01 Apr 25, 2026
fb2046e
Fixed Datatable DOM issue selecting only current page
gobears01 Apr 25, 2026
c5d961a
Fix lms_name to id
gobears01 Apr 30, 2026
a52a4eb
Disable RuboCop for intentional update_all
gobears01 Apr 30, 2026
b12824a
Merge pull request #353 from cs169/enable-assignments
gobears01 Apr 30, 2026
af656f6
added 2 buttons to allow all assignments to be enabled or disables
Shklalom Apr 25, 2026
ba2d2b6
Fixed Ruby lint and replaced updateAll method in bulkAssignmentUpdate…
gobears01 Apr 25, 2026
c7afd3e
Fixed Datatable DOM issue selecting only current page
gobears01 Apr 25, 2026
6b4f2a1
Fix lms_name to id
gobears01 Apr 30, 2026
27a9cbc
Disable RuboCop for intentional update_all
gobears01 Apr 30, 2026
0aa2041
Async sync enrollments and sync assignments, added UI to indicate pen…
gobears01 Apr 8, 2026
c4803a2
Add RSpec and Cucumber tests for async syncs
gobears01 Apr 10, 2026
22222b9
Fixed Ruby lint
gobears01 Apr 10, 2026
3a9985f
Retrigger CI
gobears01 Apr 20, 2026
a08b4ea
Resolved merge conflict sync_assignment method
gobears01 Apr 24, 2026
19d8c6b
Trigger CI
gobears01 Apr 30, 2026
9ad05ad
Trigger CI
gobears01 Apr 30, 2026
5200f53
Merge branch 'main' into gem-dashboards
gobears01 Apr 30, 2026
0554ccd
Remove synchrounous sync function in assignments
gobears01 May 1, 2026
29a0928
Abstract a function to a new file
gobears01 May 1, 2026
82c4573
Authentication added for sync_status, error handling for lms_to_couse…
gobears01 May 1, 2026
b2aecfe
lorage interface cgeck instead of controller check
gobears01 May 1, 2026
35a3907
Merge pull request #346 from cs169/gem-dashboards
alxstx May 1, 2026
f2a3140
Added UI dashboards
gobears01 Apr 24, 2026
3d14146
Fixed headers and added admin tools heading
gobears01 Apr 24, 2026
66c5d9b
Added cucumber scenariosfor admin tools
gobears01 Apr 24, 2026
ddc9052
Added ID for navbar and tests
gobears01 Apr 24, 2026
2217902
More specific about where sync_poller lives
gobears01 May 1, 2026
960705d
Make datatable css inline with application css
gobears01 May 1, 2026
5076ef9
files for datatables
gobears01 May 1, 2026
6bec275
Datatables refactored
gobears01 May 1, 2026
584298b
Merge origin/main into admin-UI (#404)
cycomachead Jul 14, 2026
18ecd46
Configure GoodJob to run in-process on Elastic Beanstalk (single inst…
cycomachead Jul 15, 2026
768f1a2
Fix student-notes a11y color-contrast violations
cycomachead Jul 16, 2026
73b4324
Merge origin/main into admin-UI (#404)
cycomachead Jul 16, 2026
89aa115
Merge origin/main into admin-UI (#404)
cycomachead Jul 16, 2026
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
7 changes: 7 additions & 0 deletions Procfile
Original file line number Diff line number Diff line change
@@ -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
2 changes: 0 additions & 2 deletions app/assets/stylesheets/application.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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} {
Expand Down
25 changes: 24 additions & 1 deletion app/controllers/courses_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/session_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
100 changes: 77 additions & 23 deletions app/javascript/controllers/assignment_controller.js
Original file line number Diff line number Diff line change
@@ -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')) {
Expand Down Expand Up @@ -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");
}
}

}
58 changes: 34 additions & 24 deletions app/javascript/controllers/enrollments_controller.js
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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");
}
}

}
11 changes: 11 additions & 0 deletions app/javascript/controllers/sync_poller.js
Original file line number Diff line number Diff line change
@@ -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.");
}
4 changes: 2 additions & 2 deletions app/models/course.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 16 additions & 12 deletions app/views/courses/enrollments.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -81,18 +81,22 @@
</table>
</div>

<div class="text-start mt-4">
<button class="btn btn-outline-primary"
data-controller="enrollments"
data-action="click->enrollments#sync"
data-enrollments-course-id-value="<%= @course.id %>">
Sync Enrollments
</button>
<p class="text-muted mt-2 mb-0">
Enrollments last synced at
<%= @enrollments_last_synced_at ? @enrollments_last_synced_at.strftime('%a, %b %-d at %-I:%M%P') : 'never' %>
</p>
</div>
<% if @course.staff_user?(current_user) %>
<div class="text-start mt-4">
<button class="btn btn-outline-primary"
data-controller="enrollments"
data-action="click->enrollments#sync"
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>
<p class="text-muted mt-2 mb-0">
Enrollments last synced at
<%= @enrollments_last_synced_at ? @enrollments_last_synced_at.strftime('%a, %b %-d at %-I:%M%P') : 'never' %>
</p>
</div>
<% end %>
</div>
</div>
</div>
22 changes: 17 additions & 5 deletions app/views/courses/instructor_show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,25 @@
</tbody>
</table>
</div>

<div class="text-start mt-4">
<button class="btn btn-outline-primary"
<div class="text-center d-flex justify-content-center gap-2"
data-controller="assignment"
data-assignment-course-id-value="<%= @course.id %>"
data-assignment-bulk-url-value="<%= bulk_update_assignments_course_path(@course) %>">
<button class="btn btn-success"
data-action="click->assignment#enableAll">
Enable All
</button>
<button class="btn btn-secondary"
data-action="click->assignment#disableAll">
Disable All
</button>
<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>
<p class="text-muted mt-2 mb-0">
Assignments last synced at
Expand Down
8 changes: 7 additions & 1 deletion app/views/layouts/_navbar.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,13 @@
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle testid-username" href="#" data-bs-toggle="dropdown"
aria-expanded="false" aria-label="User menu for <%= session[:username] %>"><%= session[:username] %></a>
<ul class="dropdown-menu">
<ul class="dropdown-menu" id="user-dropdown-menu">
<% if current_user.admin? %>
<li><h6 class="dropdown-header">Admin Tools</h6></li>
<li><a class="dropdown-item" href="/admin/blazer">Dashboards</a></li>
<li><a class="dropdown-item" href="/admin/good_job">Background Jobs</a></li>
<li><hr class="dropdown-divider"></li>
<% end %>
<li><a class="dropdown-item testid-logout-button" href="/logout/">Logout</a></li>
</ul>
</li>
Expand Down
2 changes: 2 additions & 0 deletions app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
<%= csp_meta_tag %>

<%= stylesheet_link_tag "application" %>
<link rel="stylesheet" href="/datatables/dataTables.bootstrap5.css">
<link rel="stylesheet" href="/datatables/responsive.bootstrap5.css">

<%= javascript_importmap_tags %>
<%= render "layouts/components/color_mode_critical" %>
Expand Down
Loading
Loading