Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
06fda77
Some local changes for development
gobears01 Feb 13, 2026
ae372e1
Added UI for new approve extended extensions switch
gobears01 Feb 16, 2026
d8ece10
Add two API endpoints for fixing assignment due dates
cycomachead Feb 17, 2026
87681cf
Updated backend when using' Approved Extended Requests?' toggle
gobears01 Feb 18, 2026
e00f0ee
Merge pull request #313 from cs169/add-new-api-scopes
noahnizamian Feb 20, 2026
81ba7f2
Finished the course settings and approval logic for dsp
gobears01 Feb 20, 2026
2400723
undo changes to Ruby version
gobears01 Feb 20, 2026
9ab1d1f
Reverted development changes to Gemfile
gobears01 Feb 20, 2026
657e5b9
Fixed sync enrollments error where users were getting recreated and m…
gobears01 Feb 20, 2026
36e9c4b
Add tests for enrollments-to-requests search filter
alxstx Feb 20, 2026
c47968d
Add step definitions and test data for enrollments-to-requests
alxstx Feb 20, 2026
8255280
Add
alxstx Feb 20, 2026
f700f0e
Added rspec and cucumber testing for DSP auto requests
gobears01 Feb 21, 2026
2f2d7e6
Add missing tests and fix minor issues for auto extended requests
ba88im Feb 26, 2026
bed1e14
Merge pull request #319 from cs169/basim/review-auto-extended-requests
gobears01 Feb 26, 2026
e37eb6d
Merge pull request #316 from cs169/data-search-query-branch
gobears01 Feb 28, 2026
bddf989
Addressing comments on pull request
gobears01 Mar 4, 2026
1941690
Update tests for students missing enrollment
gobears01 Mar 4, 2026
94b9d71
Merge branch 'main' into auto-extended-requests
gobears01 Mar 4, 2026
fcf38f1
Added the changes per prof Ball requests
gobears01 Mar 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
1 change: 1 addition & 0 deletions app/controllers/courses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def enrollments
return redirect_to courses_path, alert: 'You do not have access to this page.' unless @role == 'instructor'

@enrollments = @course.user_to_courses.includes(:user)
@is_course_admin = @course.user_to_courses.find_by(user: @user)&.course_admin?
end

def delete
Expand Down
25 changes: 25 additions & 0 deletions app/controllers/user_to_courses_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
class UserToCoursesController < ApplicationController
before_action :authenticate_user
before_action :set_course
before_action :ensure_course_admin

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

if @enrollment.update(allow_extended_requests: params[:allow_extended_requests])
render json: { success: true }, status: :ok
else
flash[:alert] = "Failed to update enrollment: #{@enrollment.errors.full_messages.to_sentence}"
render json: { redirect_to: course_path(@course) }, status: :unprocessable_entity
end
end

private

def ensure_course_admin
enrollment = @course.user_to_courses.find_by(user: @user)
return if enrollment&.course_admin?

render json: { error: 'You must be an instructor or Lead TA.', redirect_to: course_path(@course) }, status: :forbidden
end
end
3 changes: 3 additions & 0 deletions app/facades/canvas_facade.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ class CanvasAPIError < LmsFacade::LmsAPIError; end
'url:GET|/api/v1/courses/:course_id/assignments',
'url:PUT|/api/v1/courses/:course_id/assignments/overrides',
'url:POST|/api/v1/courses/:course_id/assignments/overrides',
# Assignment Date Extension Details
'url:GET|/api/v1/courses/:course_id/assignments/:assignment_id/date_details',
'url:GET|/api/v1/courses/:course_id/quizzes/:quiz_id/date_details',
# Assignments - Basic Info
'url:GET|/api/v1/courses/:course_id/assignments',
'url:GET|/api/v1/courses/:course_id/assignments/:id',
Expand Down
55 changes: 47 additions & 8 deletions app/javascript/controllers/enrollments_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import "datatables.net-responsive";
import "datatables.net-responsive-bs5";

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

connect() {
Expand All @@ -25,23 +26,61 @@ export default class extends Controller {
responsive: true,
pageLength: 500,
lengthMenu: [[-1, 25, 50, 100, 500], ["All", 25, 50, 100, 500]],
columns: [
null, // Name
null, // Email
null, // Section
{ orderDataType: 'role-pre' } // Role column (custom sort)
],
columns: document.querySelectorAll('#enrollments-table thead th').length === 5
? [null, null, null, { orderDataType: 'role-pre' }, null]
: [null, null, null, { orderDataType: 'role-pre' }],
order: [[3, 'des'], [0, 'asc']] // Sort Role first, then Name
});
}
}

async toggleExtended(event) {
const checkbox = event.currentTarget;
const url = checkbox.dataset.url;
const allowExtended = checkbox.checked;

try {
const token = document.querySelector('meta[name="csrf-token"]')?.content || '';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fine for now, though if we do more AJAX-y things, I think we should write some fetchWithToken method or something that shared this logic.


const response = await fetch(url, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": token,
},
body: JSON.stringify({
allow_extended_requests: allowExtended,
}),
});

const data = await response.json();

if (!response.ok) {
if (data.redirect_to) {
window.location.href = data.redirect_to;
return;
}
throw new Error(data.error || 'Error updating enrollment');
}

const td = checkbox.closest('td');
if (td) td.dataset.order = allowExtended ? '1' : '0';
this._dispatchFlash('notice', `Extended requests ${allowExtended ? 'enabled' : 'disabled'}.`);
} catch (error) {
console.error("Error updating enrollment:", error);
checkbox.checked = !allowExtended;
}
}

_dispatchFlash(type, message) {
window.dispatchEvent(new CustomEvent('flash', { detail: { type: type, message: message } }));
}

sync() {
const button = event.currentTarget;
button.disabled = true;
const courseId = this.courseIdValue;
const token = document.querySelector('meta[name="csrf-token"]').content;
fetch(`/courses/${courseId}/sync_enrollments`, {
const token = document.querySelector('meta[name="csrf-token"]').content; fetch(`/courses/${courseId}/sync_enrollments`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Expand Down
31 changes: 21 additions & 10 deletions app/javascript/controllers/flash_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,27 @@ export default class extends Controller {
: type === "alert" ? "alert-danger"
: "alert-info"

// wrap in a full-width row if you want
const wrapper = document.createElement("div")
wrapper.className = "col-12"
wrapper.innerHTML = `
<div class="alert ${bsClass} alert-dismissible fade show" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`
container.appendChild(wrapper)
const render = () => {
container.innerHTML = ''
const wrapper = document.createElement("div")
wrapper.className = "col-12"
wrapper.innerHTML = `
<div class="alert ${bsClass} alert-dismissible fade show" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`
container.appendChild(wrapper)
container.style.opacity = '1'
}

if (container.innerHTML.trim()) {
container.style.transition = 'opacity 0.15s'
container.style.opacity = '0'
setTimeout(render, 150)
} else {
render()
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion app/jobs/sync_users_from_canvas_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def sync_users_for_role(course, user, role)
Rails.logger.error "Unexpected response from Canvas API: #{canvas_users.inspect}"
return { added: 0, removed: 0, updated: 0 }
end
current_canvas_user_ids = canvas_users.pluck('id')
current_canvas_user_ids = canvas_users.pluck('id').map(&:to_s)

users_added = 0
users_removed = 0
Expand Down
6 changes: 6 additions & 0 deletions app/models/course_settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ class CourseSettings < ApplicationRecord
validate :gradescope_url_is_valid, if: :enable_gradescope?
after_save :create_or_update_gradescope_link

def automatic_approval_enabled?
return false unless enable_extensions?

auto_approve_days.positive? || auto_approve_extended_request_days.positive?
end

def ensure_system_user_for_auto_approval
# Create the system user if auto-approval is being enabled
return unless enable_extensions && auto_approve_days.to_i.positive?
Expand Down
17 changes: 11 additions & 6 deletions app/models/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -113,17 +113,22 @@ def try_auto_approval(_current_user)
def auto_approval_eligible_for_course?
return false if course&.course_settings.blank?

course.course_settings.enable_extensions &&
course.course_settings.auto_approve_days.positive?
course.course_settings.automatic_approval_enabled?
end

def eligible_for_auto_approval?
return false if course&.course_settings.blank?
return false unless course.course_settings.enable_extensions?
return false if course.course_settings.auto_approve_days.zero?
return false unless auto_approval_eligible_for_course?

enrollment = UserToCourse.find_by(user: user, course: course)
return false if enrollment.nil?
if enrollment.allow_extended_requests
max_days = course.course_settings.auto_approve_extended_request_days
else
max_days = course.course_settings.auto_approve_days
end

days_difference = calculate_days_difference
return false if days_difference <= 0 || days_difference > course.course_settings.auto_approve_days
return false if days_difference <= 0 || (days_difference > max_days)

max_approvals = course.course_settings.max_auto_approve
return true if max_approvals.zero? # If max is 0, there's no limit
Expand Down
4 changes: 1 addition & 3 deletions app/views/courses/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,8 @@
step: 1,
class: 'form-control',
id: 'auto-approve-dsp',
placeholder: 'Number of days',
disabled: true %>
placeholder: 'Number of days' %>
<small class="text-muted">
<em>This feature is currently under construction.</em>
This allows students who have 'Allow Extended Requests' checked to have requests automatically approved within an extended timeframe. Use this for students who have extenuating circumstances.
</small>
</div>
Expand Down
36 changes: 35 additions & 1 deletion app/views/courses/enrollments.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@
<th class="text-center">Student ID</th>
<th class="text-center">Email</th>
<th class="text-center">Role</th>
<% if @is_course_admin %>
<th class="text-center">
Extended Requests?
<a tabindex="0"
role="button"
data-bs-toggle="popover"
data-bs-trigger="focus"
data-bs-html="true"
data-bs-content="Allow students to have an additional window for automatically approved requests, such as for extenuating circumstances. Set the limit from <a href='<%= course_settings_path %>'>the settings page</a>."
aria-label="Help: additional request window settings">
<i class="fas fa-circle-question" aria-hidden="true"></i>
</a>
</th>
<% end %>
</tr>
</thead>
<tbody>
Expand All @@ -27,7 +41,7 @@
<%= enrollment.user.name %> <i class="fas fa-arrow-right"></i>
<% end %>
</span>
<% if enrollment.role.downcase == 'student' %>
<% if enrollment.student? %>
Comment thread
cycomachead marked this conversation as resolved.
<span>
<%= link_to new_course_request_path(@course, "request[user_id]": enrollment.user.id), title: "Create request for this student" do %>
<i class="fas fa-plus-circle"></i>
Expand All @@ -38,6 +52,26 @@
<td class="text-center"><%= enrollment.user.student_id %></td>
<td class="text-center"><%= enrollment.user.email %></td>
<td class="text-center"><%= enrollment.role.downcase.capitalize %></td>
<% if @is_course_admin %>
<td class="justify-content-center align-content-center"
data-order="<%= enrollment.allow_extended_requests ? 1 : 0 %>">
<% if enrollment.student? %>
<div class="form-check form-switch mx-auto" style="width: 45px;">
<input class="form-check-input" type="checkbox"
role="switch"
id="extended-<%= enrollment.id %>-enabled"
<%= 'checked' if enrollment.allow_extended_requests %>
data-enrollments-target="checkbox"
data-action="change->enrollments#toggleExtended"
data-enrollment-id="<%= enrollment.id %>"
data-url="<%= toggle_allow_extended_requests_course_user_to_course_path(@course, enrollment) %>">
<label class="visually-hidden" for="extended-<%= enrollment.id %>-enabled">
Approve extended requests for <%= enrollment.user.name %>
</label>
</div>
<% end %>
</td>
<% end %>
</tr>
<% end %>
</tbody>
Expand Down
1 change: 1 addition & 0 deletions app/views/requests/instructor_index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
<table class="table table-bordered table-striped datatable"
id="requests-table"
data-controller="requests"
data-search-query="<%= @search_query %>"
data-readonly-token="<%= @course.readonly_api_token %>"
data-course-id="<%= @course.id %>">
<thead>
Expand Down
5 changes: 5 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@
get :export, defaults: { format: :csv }
end
end
resources :user_to_courses, only: [] do
member do
patch :toggle_allow_extended_requests
end
end
resource :form_setting, only: [:edit, :update]
end
post 'course_settings/update'
Expand Down
26 changes: 25 additions & 1 deletion features/enrollments.feature
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,28 @@ Feature: Course Enrollments
And I should see "User 2"
And I should see "User 3"
And I should see "User 4"
And I should see "User 5"
And I should see "User 5"

@javascript
Scenario: Instructor toggles "Approved Extended?" on for a student
Given I'm logged in as a teacher
When I go to the Course Enrollments page
And I toggle "Approved Extended?" for "User 3"
Then the enrollment for "User 3" should allow extended requests

@javascript
Scenario: Instructor toggles "Approved Extended?" off for a student
Given I'm logged in as a teacher
And the enrollment for "User 3" allows extended requests
When I go to the Course Enrollments page
And I toggle "Approved Extended?" for "User 3"
Then the enrollment for "User 3" should disallow extended requests

@javascript
Scenario: Clicking a student name on Enrollments navigates to filtered requests
Given a request exists for student "User 3"
And I'm logged in as a teacher
When I go to the Course Enrollments page
And I click the name link for student "User 3"
Then I should be on the "Requests page" with param show_all=true
And the requests table search should be filtered
47 changes: 47 additions & 0 deletions features/step_definitions/custom_steps.rb
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,33 @@
page.set_rack_session('flash' => { 'notice' => message })
end

# Toggle "Approved Extended?" checkbox for a student by name
# And I toggle "Approved Extended?" for "User 3"
When(/^I toggle "Approved Extended\?" for "([^"]*)"$/) do |user_name|
within(:xpath, "//tr[td[contains(., '#{user_name}')]]") do
find('input[type="checkbox"]').click
end
# Wait for the PATCH request to complete
sleep 1
end

# Check that the enrollment's allow_extended_requests is enabled/disabled
# Then the enrollment for "User 3" should allow/disallow extended requests
Then(/^the enrollment for "([^"]*)" should (allow|disallow) extended requests$/) do |user_name, state|
user = User.find_by!(name: user_name)
enrollment = UserToCourse.find_by!(user: user, course: @course, role: 'student')
expected = (state == 'allow')
expect(enrollment.reload.allow_extended_requests).to eq(expected)
end

# Set up an enrollment with allow_extended_requests enabled
# Given the enrollment for "User 3" allows extended requests
Given(/^the enrollment for "([^"]*)" allows extended requests$/) do |user_name|
user = User.find_by!(name: user_name)
enrollment = UserToCourse.find_by!(user: user, course: @course, role: 'student')
enrollment.update!(allow_extended_requests: true)
end

# this step is necessary to workaround ajax call to enable assignments
# And I enable "Homework 1"
Given(/^I (enable|disable) "([^"]*)"$/) do |action, assignment_name|
Expand Down Expand Up @@ -148,6 +175,26 @@

# Stubbing the Deny controller
# Given I deny the request for "Homework 3"
# Create a request for a specific student using factory data
Given(/^a request exists for student "([^"]*)"$/) do |student_name|
student = User.find_by(name: student_name)
assignment = Assignment.first
create(:request, user: student, course: @course, assignment: assignment)
end

# Click a specific student's name link on the enrollments page
When(/^I click the name link for student "([^"]*)"$/) do |student_name|
within('#enrollments-table') do
click_link student_name
end
end

# Verify the DataTable search input has a value (i.e., filter is active)
Then(/^the requests table search should be filtered$/) do
search_input = find('.dt-search input')
expect(search_input.value).not_to be_empty
end

Given(/^I deny the request for "([^"]*)"$/) do |assignment_name|
request = Request.joins(:assignment)
.find_by(assignments: { name: assignment_name }, status: 'pending')
Expand Down
Loading