Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
117 commits
Select commit Hold shift + click to select a range
9d54d77
Bump puma from 7.1.0 to 7.2.0
dependabot[bot] Feb 2, 2026
25440d9
Bump bootsnap from 1.20.1 to 1.21.1
dependabot[bot] Jan 15, 2026
130dcf0
Bump pg from 1.6.2 to 1.6.3
dependabot[bot] Jan 15, 2026
2d2df34
Add a /help redirect, move some routes around
cycomachead Jan 15, 2026
d7d3f5a
Allow overriding PORT
cycomachead Jan 20, 2026
6fa8036
Gradescope note
cycomachead Feb 2, 2026
21ab42f
improved GS user copy
cycomachead Feb 11, 2026
2bfee06
gitignore
cycomachead Feb 11, 2026
46e180c
Bump sentry-rails from 6.2.0 to 6.3.0
dependabot[bot] Feb 6, 2026
7901b6d
Bump axe-core-cucumber from 4.11.0 to 4.11.1
dependabot[bot] Feb 9, 2026
c0b1dd2
Bump bootstrap from 5.3.5 to 5.3.8
dependabot[bot] Feb 6, 2026
85b270e
Bump turbo-rails from 2.0.21 to 2.0.23
dependabot[bot] Feb 2, 2026
2c3b5a5
Bump rubocop from 1.82.1 to 1.84.2
dependabot[bot] Feb 16, 2026
fc075bb
Bump bootsnap from 1.21.1 to 1.23.0
dependabot[bot] Feb 16, 2026
f9b895f
Bump sentry-ruby from 6.3.0 to 6.3.1
dependabot[bot] Feb 16, 2026
9208b4e
Bump annotaterb from 4.20.0 to 4.22.0
dependabot[bot] Feb 12, 2026
a435169
Bump faraday in the bundler group across 1 directory
dependabot[bot] Feb 9, 2026
5181fd6
Bump brakeman from 7.1.2 to 8.0.2
dependabot[bot] Feb 17, 2026
1606bcc
Bump selenium-webdriver from 4.40.0 to 4.41.0
dependabot[bot] Feb 20, 2026
b148ff8
Bump rspec-rails from 8.0.2 to 8.0.3
dependabot[bot] Feb 18, 2026
3f2fe32
Bump json from 2.18.0 to 2.18.1
dependabot[bot] Feb 16, 2026
bfc40b8
Bump sentry-rails from 6.3.0 to 6.3.1
dependabot[bot] Feb 16, 2026
baf9e83
Bump fast-xml-parser in the npm_and_yarn group across 1 directory
dependabot[bot] Feb 18, 2026
90fa511
update psql version and enable enhanced cloudwatch metrics
dg-ucb Jan 29, 2026
9a7785a
Fix cloudwatch agent config race condition
dg-ucb Feb 12, 2026
4f86893
Fix undefined method `map` for nil when approving extension requests
cycomachead Feb 24, 2026
596b038
Publish developers doc
brandosu Feb 25, 2026
9308345
Bump sentry-ruby from 6.3.1 to 6.4.0
dependabot[bot] Feb 26, 2026
d9abf76
Bump sentry-rails from 6.3.1 to 6.4.0
dependabot[bot] Feb 26, 2026
cbf7785
Initial plan
Copilot Feb 25, 2026
3b62f62
Add updates page to docs with initial release entry and GitHub releas…
Copilot Feb 25, 2026
76fa6be
feat: handle late due dates for Canvas and Gradescope extensions
cycomachead Feb 4, 2026
891ebb4
delint part 1
cycomachead Feb 11, 2026
8735285
cleanup lints
cycomachead Feb 11, 2026
611a693
run migrations
cycomachead Feb 11, 2026
c4ba73a
fixup spec
cycomachead Feb 11, 2026
c9068e3
delete test case which seems impossible
cycomachead Feb 12, 2026
a8de5bf
cleanup the Canvas facade to not always send a late due date
cycomachead Feb 12, 2026
934d122
ignore claude
cycomachead Feb 12, 2026
9ce6820
Reorder settings
cycomachead Feb 12, 2026
59d2254
Rebased branch
gobears01 Mar 18, 2026
d79067b
delint part 1
cycomachead Feb 11, 2026
e0bba4d
cleanup lints
cycomachead Feb 11, 2026
f6ef322
fixup spec
cycomachead Feb 11, 2026
226f7e1
delete test case which seems impossible
cycomachead Feb 12, 2026
b1f89d6
cleanup the Canvas facade to not always send a late due date
cycomachead Feb 12, 2026
b9353e0
Reorder settings
cycomachead Feb 12, 2026
fefa05f
refactor: extract extension date logic to AssignmentDateCalculator
cycomachead Feb 12, 2026
bb88f17
minor delint
cycomachead Feb 20, 2026
9896773
tidy specs
cycomachead Feb 20, 2026
0b1f252
Tidy rubocop
cycomachead Feb 20, 2026
c24badc
Add a few notes
cycomachead Feb 27, 2026
08c4bc5
tidy docs, add DOI info
cycomachead Feb 27, 2026
11034a9
more updates
cycomachead Feb 27, 2026
d11d44e
Add project info.yml metadata
ba88im Mar 5, 2026
8b05efa
Add tests for enrollments-to-requests search filter
alxstx Feb 20, 2026
3b9382e
Add step definitions and test data for enrollments-to-requests
alxstx Feb 20, 2026
01dc86f
Bump brakeman from 8.0.2 to 8.0.4
dependabot[bot] Feb 27, 2026
22ef95f
Update a bunch of smaller dependencies for testing, etc
cycomachead Mar 5, 2026
f7c7b07
update sentry
cycomachead Mar 5, 2026
4adc27b
bump rubocop-rails and delint
cycomachead Mar 5, 2026
0f4e560
bump rubocop-rspec and delint
cycomachead Mar 5, 2026
31fa074
bump mail gem
cycomachead Mar 9, 2026
333419b
bundle update guard / deps
cycomachead Mar 9, 2026
114efed
bump globalid
cycomachead Mar 9, 2026
036cd72
Minor transitive deps updates
cycomachead Mar 9, 2026
5606c16
More transitive deps updates
cycomachead Mar 9, 2026
73a1b96
Bump the npm_and_yarn group across 1 directory with 2 updates
dependabot[bot] Feb 26, 2026
2b68ba7
Bump dompurify in the npm_and_yarn group across 1 directory
dependabot[bot] Mar 10, 2026
38841d1
Added the changes per prof Ball requests
gobears01 Mar 16, 2026
5acc7b2
delint
cycomachead Mar 17, 2026
9cfbbb3
Rename README to make docs more clear
cycomachead Mar 17, 2026
d2ae5c3
Setup local github pages in docs directory
cycomachead Mar 17, 2026
099d3ea
WIP tidy docs
cycomachead Mar 17, 2026
9cd9d31
minor README tweaks
cycomachead Mar 17, 2026
63a1380
Fix some small issues
cycomachead Mar 17, 2026
7e0e8d1
Add environment variable to docs build
cycomachead Mar 17, 2026
4f9ef23
More docs updates
cycomachead Mar 17, 2026
3ff2c32
Bump undici in the npm_and_yarn group across 1 directory
dependabot[bot] Mar 14, 2026
15e185c
Run bundle update --minor
cycomachead Mar 17, 2026
6f55b2d
Moved approve/ reject selected buttons to the bottom of the requests …
gobears01 Mar 17, 2026
fb80739
Moved Approve/ Reject Selected buttons to the left, changed checkboxe…
gobears01 Mar 18, 2026
a409b92
added student notes + tests
noahnizamian Apr 5, 2026
fa3a1d6
Add updated schema.rb for student notes migration
noahnizamian Apr 5, 2026
1e4fce3
fixed tests
noahnizamian Apr 5, 2026
ab480a1
Add API token model, management UI, and migration tooling
ba88im Apr 9, 2026
9a72c8c
Fix rubocop offenses and update schema.rb for CI
ba88im Apr 9, 2026
a6b5d4b
Fix Zeitwerk naming and Rubocop DynamicFindBy
ba88im Apr 9, 2026
404be48
Fix token generation: use before_validation instead of before_create
ba88im Apr 9, 2026
95096f7
Fix association tests: replace shoulda-matchers with plain RSpec
ba88im Apr 9, 2026
17f206f
Address PR review feedback for API tokens
ba88im Apr 17, 2026
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
15d7042
Added UI dashboards
gobears01 Apr 24, 2026
2c024bb
Fixed headers and added admin tools heading
gobears01 Apr 24, 2026
6a98d64
Added cucumber scenariosfor admin tools
gobears01 Apr 24, 2026
322f42b
Added ID for navbar and tests
gobears01 Apr 24, 2026
6f2cdb0
Merge remote-tracking branch 'origin/main' into ba88im-superset/ba88i…
ba88im Apr 24, 2026
23f0263
Add Enable All / Disable All assignments bulk action (#273)
ba88im Apr 27, 2026
37c1893
Merge PR #354: Add Enable All / Disable All assignments bulk action
ba88im Apr 27, 2026
a5abb9a
Merge origin/admin-UI: async sync UI, dashboards, admin tools
ba88im Apr 27, 2026
8a8e0d1
Merge origin/student_notes: per-student notes on user_to_courses
ba88im Apr 27, 2026
8733a61
Merge origin/mass-approve-decline (PR #330): mass approve/decline UI …
ba88im Apr 27, 2026
a8934f3
Revert "fixed tests"
ba88im Apr 27, 2026
f0e6c49
Resolve schema conflict
ba88im May 1, 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
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 @@ -73,6 +76,7 @@ gem 'dotenv-rails', require: 'dotenv/load'
#
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
14 changes: 14 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ GEM
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 @@ -219,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 @@ -386,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 @@ -633,6 +646,7 @@ DEPENDENCIES
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
28 changes: 28 additions & 0 deletions app/controllers/api_tokens_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
class APITokensController < ApplicationController
before_action :authenticated!
before_action :authenticate_user
before_action :set_course
before_action :ensure_instructor_role
before_action :set_pending_request_count

def index
@api_tokens = @course.api_tokens.includes(:user).order(created_at: :desc)
end

def destroy
@api_token = @course.api_tokens.find_by(id: params[:id])

if @api_token
@api_token.revoke!
redirect_to course_api_tokens_path(@course), notice: 'API token revoked successfully.'
else
redirect_to course_api_tokens_path(@course), alert: 'API token not found.'
end
end

private

def set_pending_request_count
@pending_requests_count = Request.where(course_id: @course&.id, status: 'pending').count
end
end
34 changes: 34 additions & 0 deletions app/controllers/assignments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,38 @@ def toggle_enabled
render json: { redirect_to: course_path(course) }, status: :unprocessable_content
end
end

def bulk_update_enabled
course = Course.find_by(id: params[:course_id])
unless course
return render json: { error: 'Course not found.' }, status: :not_found
end

@role = params[:role] || course.user_role(@user)

unless @role == 'instructor'
Rails.logger.error "Role #{@role} does not have permission to bulk toggle assignment enabled status"
flash.now[:alert] = 'You do not have permission to perform this action.'
return render json: { redirect_to: course_path(course) }, status: :forbidden
end

enabled = ActiveModel::Type::Boolean.new.cast(params[:enabled])

scope = course.assignments
if enabled
eligible_scope = scope.where.not(due_date: nil)
updated_count = eligible_scope.update_all(enabled: true)
skipped_count = scope.count - updated_count
else
updated_count = scope.update_all(enabled: false)
skipped_count = 0
end

render json: {
success: true,
enabled: enabled,
updated_count: updated_count,
skipped_count: skipped_count
}, status: :ok
end
end
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
23 changes: 19 additions & 4 deletions app/controllers/requests_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def index
def show
@assignment = @request.assignment
@number_of_days = @request.calculate_days_difference if @request.requested_due_date.present? && @assignment&.due_date.present?
@student_enrollment = @course.user_to_courses.find_by(user: @request.user) if @role == 'instructor'
render_role_based_view
end

Expand Down Expand Up @@ -175,21 +176,35 @@ def mass_reject
process_mass_action(:reject)
end

# April 2026 - Migrated `courses.readonly_api_token` values into the new `api_tokens` table.
# The `readonly_api_token` query parameter is still accepted as an alias for `api_token` so
# previously distributed CSV/Sheets URLs keep working.
# Dec 2026 (or later): It is safe to remove the deprecated `readonly_api_token` query
# parameter and this comment.
def export
course = Course.find_by(id: params[:course_id])
token = params[:readonly_api_token]
raw_token = params[:readonly_api_token] || params[:api_token]

return render plain: 'Invalid or missing API token', status: :unauthorized unless course && ActiveSupport::SecurityUtils.secure_compare(course.readonly_api_token, token.to_s)
return render plain: 'Course not found', status: :not_found unless course

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side note, I wonder if this endpoint should always return a CSV.
In the case of an error maybe it should return something like

Error, Status, Message
True,401|404|etc,the current messages...

This would make debugging things easier when they co wrong...

return render plain: 'Invalid or missing API token', status: :unauthorized if raw_token.blank?

api_token = APIToken.lookup_token(raw_token)
return render plain: 'Invalid or missing API token', status: :unauthorized unless api_token&.active? && api_token.course_id == course.id

api_token.touch_last_used!
export_csv(course)
end

private

def export_csv(course)
requests = course.requests.includes(:assignment, :user)
requests = requests.where(status: params[:status]) if params[:status].present?

csv_data = Request.to_csv(requests)
send_data csv_data, filename: 'requests.csv', type: 'text/csv'
end

private

def set_request
@side_nav = 'requests'
@request = @course.requests.includes(:assignment).find_by(id: params[:id])
Expand Down
10 changes: 10 additions & 0 deletions app/controllers/user_to_courses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ def toggle_allow_extended_requests
end
end

def update_notes
@enrollment = @course.user_to_courses.find(params[:id])

if @enrollment.update(notes: params[:notes])
render json: { success: true, notes: @enrollment.notes }, status: :ok
else
render json: { success: false, error: @enrollment.errors.full_messages.to_sentence }, status: :unprocessable_content
end
end

private

def ensure_course_admin
Expand Down
105 changes: 90 additions & 15 deletions app/javascript/controllers/assignment_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@ 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,
role: String,
userId: Number
}

connect() {
this.checkboxTargets.forEach((checkbox) => {
Expand Down Expand Up @@ -64,31 +69,101 @@ export default class extends Controller {
}
}

sync(event) {
const button = event.currentTarget;
enableAll(event) {
if (!confirm("Enable all assignments for this course? Assignments without a due date will be skipped.")) return;
this._bulkUpdate(event.currentTarget, true);
}

disableAll(event) {
if (!confirm("Disable all assignments for this course?")) return;
this._bulkUpdate(event.currentTarget, false);
}

_bulkUpdate(button, enabled) {
const url = this.bulkUrlValue;
if (!url) {
console.error("bulk-url-value is not set on the assignment controller element");
return;
}
button.disabled = true;
const courseId = this.courseIdValue;
const token = document.querySelector('meta[name="csrf-token"]').content;
fetch(`/courses/${courseId}/sync_assignments`, {
method: "POST",
fetch(url, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": token,
},
body: JSON.stringify({
course_id: this.courseIdValue,
enabled: enabled,
role: this.hasRoleValue ? this.roleValue : undefined,
user_id: this.hasUserIdValue ? this.userIdValue : undefined,
}),
})
.then((response) => {
.then(async (response) => {
const data = await response.json();
if (!response.ok) {
throw new Error("Failed to sync assignments.");
if (data.redirect_to) {
window.location.href = data.redirect_to;
return;
}
throw new Error(data.error || "Failed to update assignments.");
}
return response.json();
})
.then((data) => {
flash("notice", data.message || "Assignments synced successfully.");
const action = enabled ? "enabled" : "disabled";
let message = `${data.updated_count} assignment(s) ${action}.`;
if (data.skipped_count && data.skipped_count > 0) {
message += ` ${data.skipped_count} skipped (missing due date).`;
}
flash("notice", message);
location.reload();
})
.catch((error) => {
flash("alert", error.message || "An error occurred while syncing assignments.");
location.reload();
flash("alert", error.message || "An error occurred while updating assignments.");
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;

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.");
}
}
Loading
Loading