Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
37b1a1f
LMS credentials now uses LMS ID instead of LMS name
noahnizamian Mar 5, 2026
748d121
fixed schema.rb
noahnizamian Mar 5, 2026
d2bbb10
Implement UserToCoursesController#toggle_allow_extended_requests
noahnizamian Mar 5, 2026
6f04fe7
patch + rspec tests
noahnizamian Mar 29, 2026
9dd0be3
Merge pull request #335 from cs169/mass-approve
gobears01 Mar 31, 2026
acfe155
Hide checkbox and status columns on mobile requests page
ba88im Apr 2, 2026
00087b0
Merge branch 'berkeley-cdss:main' into main
cycomachead Apr 14, 2026
afff93d
Merge pull request #318 from cs169/developer-login
alxstx Apr 23, 2026
98379e9
Merge pull request #337 from cs169/canvas_due_date_patch
alxstx Apr 23, 2026
8227307
Merge pull request #339 from cs169/ba88im/mobile-view-requests-page-c…
alxstx Apr 23, 2026
c457642
removed duplicate filer courses method after rebasing
gobears01 Apr 20, 2026
1281ba4
Merge remote-tracking branch 'upstream/main' into main-rebase
gobears01 Apr 24, 2026
404b449
Restore filter_by_semester method lost during merge
gobears01 Apr 24, 2026
33b2bca
Merge pull request #351 from cs169/main-rebase
gobears01 Apr 24, 2026
2f51943
Support Lead TA course role
Apr 23, 2026
ec48c39
Add pending notification columns to course_settings
Apr 6, 2026
7b06bea
Add pending notification validations, normalization, and scope to Cou…
Apr 6, 2026
6d13725
Add PendingRequestsNotificationJob to send digest emails
Apr 6, 2026
48778d8
Permit pending notification params in CourseSettingsController
Apr 6, 2026
ace581c
Add rake task for sending pending request digest emails
Apr 6, 2026
b07c24c
Add notification frequency dropdown and email field to course setting…
Apr 6, 2026
8a8984a
Fix RuboCop offenses on email-notifications branch
Apr 26, 2026
94bd760
Merge pull request #342 from alxstx/email-notifications
noahnizamian Apr 30, 2026
529ba52
Merge pull request #350 from alxstx/issue-348-lead-ta-role
noahnizamian Apr 30, 2026
72e6677
re-run CI
gobears01 Apr 30, 2026
7f86619
re-run CI
gobears01 Apr 30, 2026
42f7d9f
re-run CI
gobears01 Apr 30, 2026
fdb9990
Merge main and resolve conflicts in schema.rb and UserToCoursesContro…
gobears01 Apr 30, 2026
d1b503a
Fix authorize_instructor to include also TAs and leadTAs
gobears01 Apr 30, 2026
76bb652
Fix rubycop expecting lms id
gobears01 Apr 30, 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
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1 +1 @@
ruby 3.3.8
ruby 3.3
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ gem 'strong_migrations'
# Logging Customization
gem 'lograge'

# Environment variable management
gem 'dotenv-rails', require: 'dotenv/load'

# Use Active Storage for file uploads [https://guides.rubyonrails.org/active_storage_overview.html]
# gem "activestorage", "~> 7.0.0"

Expand Down
5 changes: 5 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ GEM
diff-lcs (1.6.2)
docile (1.4.1)
domain_name (0.6.20240107)
dotenv (3.2.0)
dotenv-rails (3.2.0)
dotenv (= 3.2.0)
railties (>= 6.1)
drb (2.2.3)
dumb_delegator (1.1.0)
erb (6.0.2)
Expand Down Expand Up @@ -624,6 +628,7 @@ DEPENDENCIES
cucumber-rails
database_cleaner-active_record
debug
dotenv-rails
factory_bot_rails
faraday
faraday-cookie_jar
Expand Down
4 changes: 3 additions & 1 deletion app/controllers/course_settings_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ def course_settings_params
:email_subject,
:email_template,
:enable_slack_webhook_url,
:slack_webhook_url
:slack_webhook_url,
:pending_notification_frequency,
:pending_notification_email
)
end

Expand Down
15 changes: 7 additions & 8 deletions app/controllers/courses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ class CoursesController < ApplicationController
before_action :determine_user_role

def index
teacher_courses = UserToCourse.includes(:course).where(user: @user, role: %w[teacher ta])
teacher_courses = UserToCourse.includes(:course).where(user: @user, role: UserToCourse.staff_roles)
@teacher_courses_by_semester = group_by_semester(teacher_courses)

# Only show courses to students if extensions are enabled at the course level
Expand Down Expand Up @@ -44,16 +44,14 @@ def new
@courses = Course.fetch_courses(token)
flash[:alert] = 'No courses found.' if @courses.empty?

# Collect unique semester names from Canvas term data for the filter dropdown
@semesters = @courses.filter_map { |c| c.dig('term', 'name') }.uniq.sort
@selected_semester = params[:semester]

teacher_enrollment_types = %w[teacher ta]
# TODO: Add spec for when a course is created, but the user is not enrolled in it.
# TODO: Why do some courses have empty enrollments?
existing_canvas_ids = @user.courses.pluck(:canvas_id)
@courses_teacher = filter_courses(@courses, teacher_enrollment_types, existing_canvas_ids)
@courses_student = filter_courses(@courses, [ 'student' ], existing_canvas_ids)
@courses_teacher = filter_courses(@courses, UserToCourse.staff_roles, existing_canvas_ids)
@courses_student = filter_courses(@courses, [ UserToCourse::STUDENT_ROLE ], existing_canvas_ids)

if @selected_semester.present?
@courses_teacher = filter_by_semester(@courses_teacher, @selected_semester)
Expand All @@ -68,7 +66,7 @@ def edit

def create
token = @user.lms_credentials.first.token
filter_courses(Course.fetch_courses(token), %w[teacher ta])
filter_courses(Course.fetch_courses(token), UserToCourse.staff_roles)
.select { |c| params[:courses]&.include?(c['id'].to_s) }
.each { |course_api| Course.create_or_update_from_canvas(course_api, token, @user) }
redirect_to courses_path, notice: 'Selected courses and their assignments have been imported successfully.'
Expand Down Expand Up @@ -133,7 +131,6 @@ def group_by_semester(user_to_courses)
sorted_semesters.map { |semester| [ semester, grouped[semester] ] }
end

# Filters Canvas API course hashes by their term name
def filter_by_semester(courses, semester)
courses.select { |c| c.dig('term', 'name') == semester }
end
Expand All @@ -146,7 +143,9 @@ def filter_courses(courses, roles, exclude_ids = [])
courses = courses - missing_enrollments - courses.select { |course| exclude_ids.include?(course['id'].to_s) }
return [] if courses.empty?

courses.select { |course| course['enrollments'].any? { |e| roles.include?(e['type']) } }
courses.select do |course|
course['enrollments'].any? { |enrollment| roles.include?(UserToCourse.role_from_canvas_enrollment(enrollment)) }
end
end

def course_data_for_sync
Expand Down
32 changes: 28 additions & 4 deletions app/controllers/session_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,25 @@ def omniauth_callback
'email' => auth.info.email
}
creds = auth.credentials # an OmniAuth::AuthHash

# dev provider doesnt have real credentials so its stubbed
expires_at = creds.expires_at || 30.days.from_now.to_i
refresh_token = creds.refresh_token || 'none'

access_token = OAuth2::AccessToken.new(
OAuth2::Client.new('', ''), # client never used – stub
creds.token,
refresh_token: creds.refresh_token,
expires_at: creds.expires_at
refresh_token: refresh_token,
expires_at: expires_at
)

# Persist / update the user just like `create`
find_or_create_user(user_data, access_token)
user = find_or_create_user(user_data, access_token)

# Auto-enroll developer login users in test courses
if auth.provider == 'developer'
ensure_developer_test_enrollments(user)
end

redirect_to courses_path, notice: "Logged in! Welcome, #{user_data['name']}!"
rescue StandardError => e
Expand All @@ -79,6 +89,18 @@ def destroy

private

def ensure_developer_test_enrollments(user)
# Find the test course
test_course = Course.find_by(course_code: 'DEV101')

# Ensure enrollment in the test course (as student so they can request extensions)
if test_course
UserToCourse.find_or_create_by!(user_id: user.id, course_id: test_course.id) do |utc|
utc.role = 'student'
end
end
end

# TODO: Refactor.
def find_or_create_user(user_data, auth_token)
auth_token.token
Expand All @@ -102,6 +124,8 @@ def find_or_create_user(user_data, auth_token)
# Store user ID in session for authentication
session[:username] = user.name
session[:user_id] = user.canvas_uid

user
end

# TODO: Move this to a Canvas API libarary or user service
Expand All @@ -115,7 +139,7 @@ def update_user_credential(user, token)
)
else
user.lms_credentials.create!(
lms_name: 'canvas',
lms_id: Lms.CANVAS_LMS.id,
token: token.token,
refresh_token: token.refresh_token,
expire_time: Time.zone.at(token.expires_at)
Expand Down
44 changes: 34 additions & 10 deletions app/controllers/user_to_courses_controller.rb
Original file line number Diff line number Diff line change
@@ -1,25 +1,49 @@
class UserToCoursesController < ApplicationController
before_action :authenticate_user
before_action :authenticate_user!
before_action :set_course
before_action :ensure_course_admin
before_action :set_enrollment
before_action :authorize_instructor!

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_content
render json: {
success: false,
errors: @enrollment.errors.full_messages,
redirect_to: courses_path
}, status: :unprocessable_content
end
end

private

def ensure_course_admin
enrollment = @course.user_to_courses.find_by(user: @user)
return if enrollment&.course_admin?
def authenticate_user!
user_id = session[:user_id]
@current_user = User.find_by(canvas_uid: user_id) if user_id
redirect_to root_path unless @current_user
end

def set_course
@course = Course.find_by(id: params[:course_id])
unless @course
flash[:alert] = 'Course not found.'
redirect_to courses_path
end
end

def set_enrollment
@enrollment = UserToCourse.find(params[:id])
end

render json: { error: 'You must be an instructor or Lead TA.', redirect_to: course_path(@course) }, status: :forbidden
def authorize_instructor!
user_to_course = UserToCourse.find_by(user: @current_user, course: @course)
unless user_to_course&.course_admin?
render json: {
success: false,
error: 'Forbidden',
redirect_to: courses_path
}, status: :forbidden
end
end
end
19 changes: 17 additions & 2 deletions app/facades/canvas_facade.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require 'date'
require 'cgi'
require 'faraday'
require 'json'
require 'ostruct'
Expand All @@ -8,6 +9,9 @@ class CanvasFacade < LmsFacade
class CanvasAPIError < LmsFacade::LmsAPIError; end

CANVAS_URL = ENV.fetch('CANVAS_URL', nil)
CANVAS_CUSTOM_COURSE_ROLES = {
UserToCourse::LEAD_TA_ROLE => 'Lead TA'
}.freeze

# Canvas instances can scope the flextensions developer key.
# There must be one scope for each endpoint we can use.
Expand Down Expand Up @@ -161,10 +165,10 @@ def get_all_course_users(course, role = nil)
# sigh, manually construct query string until we tweak Faraday middleware
# to include :url_encoded, then use `'enrollment_type[]' : list_or_string`
query_string = 'per_page=100'
query_string += "&enrollment_type[]=#{role}" if role.is_a?(String) && role.present?
query_string += "&#{role_query_param(role)}" if role.is_a?(String) && role.present?

if role.is_a?(Array) && role.present? # rubocop:disable Style/IfUnlessModifier
query_string += role.map { |r| "&enrollment_type[]=#{r}" }.join
query_string += role.map { |r| "&#{role_query_param(r)}" }.join
end

depaginate_response(@canvas_conn.get("courses/#{course.canvas_id}/users?#{query_string}"))
Expand All @@ -189,6 +193,17 @@ def get_instructor_courses
teacher_courses + ta_courses
end

def role_query_param(role)
normalized_role = UserToCourse.normalize_role(role)
canvas_course_role = CANVAS_CUSTOM_COURSE_ROLES[normalized_role]

if canvas_course_role
"enrollment_role=#{CGI.escape(canvas_course_role)}"
else
"enrollment_type[]=#{CGI.escape(normalized_role)}"
end
end

##
# Gets a specified course that the authorized user has access to.
#
Expand Down
12 changes: 11 additions & 1 deletion app/javascript/controllers/course_settings_controller.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static targets = ["emailField", "tab", "gradescopeField", "slackWebhookField"];
static targets = ["emailField", "tab", "gradescopeField", "slackWebhookField", "pendingNotificationEmail"];

connect() {
this.toggleEmailFields();
this.toggleSlackWebhookField();
this.toggleGradescopeFields();
this.togglePendingNotificationEmail();

const gradescopeToggle = document.getElementById('enable-gradescope');
if (gradescopeToggle) {
Expand Down Expand Up @@ -53,6 +54,15 @@ export default class extends Controller {
}
}

togglePendingNotificationEmail() {
const frequencySelect = document.getElementById('pending-notification-frequency');
const emailField = document.getElementById('pending-notification-email');

if (frequencySelect && emailField) {
emailField.disabled = !frequencySelect.value;
}
}

updateUrlParam(event) {
const tabName = event.currentTarget.dataset.tab;
const url = new URL(window.location);
Expand Down
2 changes: 1 addition & 1 deletion app/javascript/controllers/enrollments_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default class extends Controller {
if (!DataTable.isDataTable('#enrollments-table')) {
// Define a custom sorting function for the Role column
DataTable.ext.type.order['role-pre'] = function (data) {
const rolePriority = { teacher: 4, ta: 2, student: 3 };
const rolePriority = { teacher: 4, leadta: 3, "lead ta": 3, ta: 2, student: 1 };
if (typeof data !== 'string') {
data = String(data).trim();
}
Expand Down
32 changes: 32 additions & 0 deletions app/jobs/pending_requests_notification_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
class PendingRequestsNotificationJob < ApplicationJob
queue_as :default

def perform(frequency)
CourseSettings.with_pending_notifications(frequency).includes(:course).find_each do |cs|
course = cs.course
pending_count = Request.where(course_id: course.id, status: 'pending').count
next if pending_count.zero?

requests_url = "#{ENV.fetch('APP_HOST', nil)}/courses/#{course.id}/requests"

EmailService.send_email(
to: cs.pending_notification_email,
from: ENV.fetch('DEFAULT_FROM_EMAIL'),
reply_to: cs.reply_email.presence || ENV.fetch('DEFAULT_FROM_EMAIL'),
subject_template: '{{pending_count}} Pending Extension Request{{plural}} - {{course_code}}',
body_template: "Hello,\n\nYou have {{pending_count}} pending extension request{{plural}} " \
"in {{course_name}} ({{course_code}}).\n\n" \
"Please review them at: {{requests_url}}\n\n" \
"Thank you,\nFlextensions",
mapping: {
'pending_count' => pending_count.to_s,
'plural' => pending_count == 1 ? '' : 's',
'course_name' => course.course_name,
'course_code' => course.course_code,
'requests_url' => requests_url
},
deliver_later: false
)
end
end
end
16 changes: 14 additions & 2 deletions app/jobs/sync_all_course_assignments_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,10 @@ def sync_assignment(course_to_lms, lms_assignment, results)

# Use shared LmsAssignment to populate Assignment
assignment.name = lms_assignment.name
assignment.due_date = lms_assignment.due_date
assignment.late_due_date = lms_assignment.late_due_date
unless preserve_existing_dates?(assignment, lms_assignment)
assignment.due_date = lms_assignment.due_date
assignment.late_due_date = lms_assignment.late_due_date
end
assignment.external_assignment_id = lms_assignment.id

if assignment.new_record?
Expand All @@ -60,4 +62,14 @@ def sync_assignment(course_to_lms, lms_assignment, results)
end
assignment.save!
end

private

def preserve_existing_dates?(assignment, lms_assignment)
return false if assignment.new_record?
return false unless lms_assignment.is_a?(Lmss::Canvas::Assignment)
return false if lms_assignment.base_date_present?

assignment.due_date.present? || assignment.late_due_date.present?
end
end
Loading
Loading