Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
0844bc6
Added developer login via omniauth
noahnizamian Feb 21, 2026
2e2d315
Updated tests
noahnizamian Feb 21, 2026
23c6fe8
linter fixes
noahnizamian Feb 21, 2026
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
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
00087b0
Merge branch 'berkeley-cdss:main' into main
cycomachead Apr 14, 2026
8835656
Added late days column
noahnizamian Apr 21, 2026
00149c7
fix cucumber tests
noahnizamian Apr 21, 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
b69af4b
Updated gemfile for the app to work locally and on heroku
gobears01 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
4b54eef
Merge pull request #329 from cs169/lms-id-fix
gobears01 Apr 30, 2026
9b19356
Rebase onto main
gobears01 Apr 30, 2026
1286916
Fixed ruby lint
gobears01 Apr 30, 2026
b429fcb
Fixed Rspec/ clearing users issue and ruby lint
gobears01 Apr 30, 2026
9182715
Fixed set_enrollment
gobears01 Apr 30, 2026
918dcc7
Merge pull request #341 from cs169/student_notes
gobears01 Apr 30, 2026
fd9b305
Merge branch 'main' into late_days_column
gobears01 Apr 30, 2026
e21a7d6
Fixed negative days issue
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
7 changes: 7 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 @@ -60,6 +63,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 All @@ -70,6 +76,7 @@ gem 'lograge'
#
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
19 changes: 19 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,16 @@ 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)
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 @@ -215,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 @@ -382,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 @@ -624,10 +641,12 @@ DEPENDENCIES
cucumber-rails
database_cleaner-active_record
debug
dotenv-rails
factory_bot_rails
faraday
faraday-cookie_jar
font-awesome-sass
good_job (~> 4.0)
guard-rspec
hypershield
importmap-rails
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
16 changes: 8 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 All @@ -95,6 +93,7 @@ def enrollments

@enrollments = @course.user_to_courses.includes(:user)
@is_course_admin = @course.course_admin?(@user)
@approved_late_days = Request.total_approved_late_days_by_user(@course)
end

def delete
Expand Down Expand Up @@ -133,7 +132,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 +144,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
1 change: 1 addition & 0 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
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
52 changes: 42 additions & 10 deletions app/controllers/user_to_courses_controller.rb
Original file line number Diff line number Diff line change
@@ -1,25 +1,57 @@
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

def update_notes
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
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

render json: { error: 'You must be an instructor or Lead TA.', redirect_to: course_path(@course) }, status: :forbidden
def set_enrollment
@enrollment = @course.user_to_courses.find(params[:id])
end

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
Loading
Loading