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
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
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
08f24b1
Authorize before loading enrollment in UserToCoursesController
cycomachead Jun 26, 2026
d9ac8ca
Preserve nil refresh_token for developer login
cycomachead Jun 26, 2026
d866268
Use URI::MailTo::EMAIL_REGEXP for notification email validation
cycomachead Jun 26, 2026
5327ab0
Merge remote-tracking branch 'origin/main' into lms-id-fix
cycomachead Jul 2, 2026
518e73d
Drop Canvas base_date preservation guard
cycomachead Jul 2, 2026
e2ead47
Gate developer-login button with Rails.env.local?
cycomachead Jul 2, 2026
bcc647d
Revert instructor requests table markup
cycomachead Jul 2, 2026
d515853
Drop dead LmsCredential comment and unused UserToCourse#teacher?
cycomachead Jul 2, 2026
880e9b2
Use normalizes for pending notification fields
cycomachead Jul 2, 2026
4f9bc02
Simplify UserToCoursesController auth filters
cycomachead Jul 2, 2026
61f9892
Extract pending-requests digest into PendingRequestsMailer
cycomachead Jul 2, 2026
a0e58f5
Merge remote-tracking branch 'origin/main' into cycomachead/97-previe…
cycomachead Jul 3, 2026
2293bd6
Merge remote-tracking branch 'origin/main' into cycomachead/97-previe…
cycomachead Jul 4, 2026
47a3598
Create test Lms rows with explicit ids to avoid pkey sequence collisions
cycomachead Jul 4, 2026
939dbe5
Merge branch 'main' into cycomachead/97-preview-only-pr-lms-id-fix/2
cycomachead Jul 4, 2026
f53284c
Delete old duplicate migrations file
cycomachead Jul 4, 2026
f00b46a
Drop duplicate RenameUserToCoursesToEnrollments migration
cycomachead Jul 4, 2026
3999671
Merge remote-tracking branch 'origin/cycomachead/97-preview-only-pr-l…
cycomachead Jul 5, 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: 3 additions & 1 deletion app/controllers/course_settings_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,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
4 changes: 2 additions & 2 deletions app/controllers/courses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ class CoursesController < ApplicationController
before_action :set_pending_request_count

def index
@staff_enrollments = Enrollment.includes(:course)
staff_enrollments = Enrollment.includes(:course)
.where(user: current_user, role: Enrollment.staff_roles)
.keep_highest_role
@staff_enrollments_by_semester = group_by_semester(@staff_enrollments)
@staff_enrollments_by_semester = group_by_semester(staff_enrollments)

# Only show courses to students if extensions are enabled at the course level
student_courses = Enrollment.includes(course: :course_settings).where(user: current_user, role: 'student')
Expand Down
32 changes: 22 additions & 10 deletions app/controllers/enrollments_controller.rb
Original file line number Diff line number Diff line change
@@ -1,25 +1,37 @@
class EnrollmentsController < ApplicationController
before_action :authenticate_user
# `authenticated!` from ApplicationController runs before this filter
before_action :set_course
before_action :ensure_course_admin
before_action :ensure_course_staff!
before_action :set_enrollment

def toggle_allow_extended_requests
@enrollment = @course.enrollments.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.enrollments.find_by(user: @user)
return if enrollment&.course_admin?
def set_enrollment
@enrollment = @course.enrollments.find(params[:id])
end

# ApplicationController#ensure_instructor_role would redirect with a flash,
# which breaks the JSON fetch from the course-settings UI. Respond with 403
# JSON so the client can surface the failure inline.
def ensure_course_staff!
return if @course.staff_user?(current_user)

render json: { error: 'You must be an instructor or Lead TA.', redirect_to: course_path(@course) }, status: :forbidden
render json: {
success: false,
error: 'Forbidden',
redirect_to: courses_path
}, status: :forbidden
end
end
5 changes: 2 additions & 3 deletions app/controllers/session_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,11 @@ def omniauth_callback

# 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: refresh_token,
refresh_token: creds.refresh_token,
expires_at: expires_at
)

Expand Down Expand Up @@ -189,7 +188,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
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
15 changes: 15 additions & 0 deletions app/jobs/pending_requests_notification_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
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"

PendingRequestsMailer.send_pending_request_notifications(cs, pending_count, requests_url)
end
end
end
40 changes: 40 additions & 0 deletions app/mailers/pending_requests_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true

# Templates and delivery helper for the "you have N pending extension requests"
# digest email that PendingRequestsNotificationJob sends. Wraps EmailService so
# the job doesn't need to know the template strings or mapping shape.
class PendingRequestsMailer
SUBJECT_TEMPLATE = '{{pending_count}} Pending Extension Request{{plural}} - {{course_code}}'

BODY_TEMPLATE = <<~BODY
Hello,

You have {{pending_count}} pending extension request{{plural}} in {{course_name}} ({{course_code}}).

Please review them at: {{requests_url}}

Thank you,
Flextensions
BODY

def self.send_pending_request_notifications(course_settings, pending_count, requests_url)
course = course_settings.course
default_from = ENV.fetch('DEFAULT_FROM_EMAIL')

EmailService.send_email(
to: course_settings.pending_notification_email,
from: default_from,
reply_to: course_settings.reply_email.presence || default_from,
subject_template: SUBJECT_TEMPLATE,
body_template: BODY_TEMPLATE,
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
7 changes: 0 additions & 7 deletions app/models/course.rb
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,6 @@ def student_user?(user)
enrollments.where(user_id: user.id).any?(&:student?)
end

# TODO: This doesn't make sense actually.
# A course can be linked to many LMSs.
# def lms_facade
# course_to_lms = CourseToLms.find_by(id: course_to_lms_id)
# Lms.facade_class(course_to_lms.lms_id)
# end

def canvas_id
external_course_id_for(CANVAS_LMS_ID)
end
Expand Down
24 changes: 20 additions & 4 deletions app/models/course_settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
# gradescope_course_url :string
# max_auto_approve :integer default(0)
# min_hours_before_deadline :integer default(0), not null
# pending_notification_email :string
# pending_notification_frequency :string
# reply_email :string
# slack_webhook_url :string
# created_at :datetime not null
Expand All @@ -34,7 +36,6 @@
# rubocop:enable Layout/LineLength

class CourseSettings < ApplicationRecord
# TODO: Remove the db default text, and use an AR validation.
DEFAULT_EMAIL_TEMPLATE = <<~LIQUID.freeze
Hello {{student_name}},

Expand All @@ -51,16 +52,31 @@ class CourseSettings < ApplicationRecord
{{course_name}} Staff
LIQUID

belongs_to :course
VALID_NOTIFICATION_FREQUENCIES = %w[daily weekly].freeze

# Courses and settings are 1:1; the course_id unique index enforces this at
# the database level.
belongs_to :course
validates :course_id, uniqueness: true

# Empty <select> and blank <input> submissions become "" — coerce to nil so
# `allow_nil` behaves as expected and unset rows compare equal.
normalizes :pending_notification_frequency, :pending_notification_email, with: ->(v) { v.presence }

before_save :ensure_system_user_for_auto_approval
# Clear a stored email when notifications are turned off, so re-enabling
# doesn't silently reuse a stale address.
before_save -> { self.pending_notification_email = nil if pending_notification_frequency.nil? }

validate :gradescope_url_is_valid, if: :enable_gradescope?
validates :pending_notification_frequency, inclusion: { in: VALID_NOTIFICATION_FREQUENCIES }, allow_nil: true
validates :pending_notification_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP },
if: -> { pending_notification_frequency.present? }
after_save :create_or_update_gradescope_link

scope :with_pending_notifications, ->(frequency) {
where(pending_notification_frequency: frequency)
.where.not(pending_notification_email: nil)
}

def automatic_approval_enabled?
return false unless enable_extensions?

Expand Down
4 changes: 1 addition & 3 deletions app/models/enrollment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,9 @@ class Enrollment < ApplicationRecord
# user holds more than one in the same course.
ROLE_PRIORITY = [ STUDENT_ROLE, TA_ROLE, LEAD_TA_ROLE, TEACHER_ROLE ].freeze

# Associations
belongs_to :user
belongs_to :course

# Validations
# NOTE: Validations are skipped when a User is created by SyncUsersFromCanvasJob
# You should update that job if these validations become complex.
# In the meantime, we can trust that the data coming from Canvas is valid.
Expand All @@ -60,7 +58,7 @@ def student?
end

def display_role
ROLE_LABELS.fetch(role, role.capitalize)
Enrollment.display_role(role)
end

# Rank of this enrollment's role; higher wins. Unknown roles rank lowest.
Expand Down
25 changes: 15 additions & 10 deletions app/models/lms.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,28 @@ class Lms < ApplicationRecord
# Relationship with Assignment
has_many :assignments

# Relationship with LmsCredential
has_many :lms_credentials, dependent: :destroy

# Singleton instances for each LMS
def self.CANVAS_LMS
@canvas_lms ||= find_or_create_by(
@canvas_lms ||= find_by(id: 1) || find_or_create_by!(
id: 1,
lms_name: 'Canvas',
lms_base_url: ENV.fetch('CANVAS_URL', ''),
use_auth_token: true
)
lms_name: 'Canvas'
) do |lms|
lms.lms_base_url = ENV.fetch('CANVAS_URL', '')
lms.use_auth_token = true
end
end

def self.GRADESCOPE_LMS
@gradescope_lms ||= find_or_create_by(
@gradescope_lms ||= find_by(id: 2) || find_or_create_by!(
id: 2,
lms_name: 'Gradescope',
lms_base_url: 'https://www.gradescope.com',
use_auth_token: false
)
lms_name: 'Gradescope'
) do |lms|
lms.lms_base_url = 'https://www.gradescope.com'
lms.use_auth_token = false
end
end

# Map a linked LMS to the appropriate API facade which can be used to post extension requests
Expand Down
14 changes: 6 additions & 8 deletions app/models/lms_credential.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
#
# id :bigint not null, primary key
# expire_time :datetime
# lms_name :string
# password :string
# refresh_token :string
# token :string
# username :string
# created_at :datetime not null
# updated_at :datetime not null
# external_user_id :string
# lms_id :bigint
# user_id :bigint
#
# Indexes
Expand All @@ -21,18 +21,16 @@
#
# Foreign Keys
#
# fk_rails_... (lms_id => lmss.id)
# fk_rails_... (user_id => users.id)
#
class LmsCredential < ApplicationRecord
# Belongs to a User
belongs_to :user
belongs_to :lms

# Encryption for tokens
encrypts :token, :refresh_token

# LMS must exist
validates :lms_name, presence: true

# Whether the access token expires within the given buffer (so callers can
# refresh it before starting a batch of API calls).
def expires_soon?(buffer = 15.minutes)
Expand Down Expand Up @@ -72,12 +70,12 @@ def refresh!
# Flextensions under Settings > Approved Integrations, or the developer
# key/scopes changed (which invalidates every token derived from the
# key). It can never work again, so remove it rather than retry it.
Rails.logger.warn "Removing revoked #{lms_name} credential for user #{user_id} (invalid_grant)"
Rails.logger.warn "Removing revoked #{lms.lms_name} credential for user #{user_id} (invalid_grant)"
destroy
else
Rails.logger.error "Failed to refresh #{lms_name} token for user #{user_id}: #{e.message}"
Rails.logger.error "Failed to refresh #{lms.lms_name} token for user #{user_id}: #{e.message}"
Rails.error.report(e, handled: true,
context: { component: 'token_refresh', lms: lms_name, user_id: user_id })
context: { component: 'token_refresh', lms: lms.lms_name, user_id: user_id })
end
nil
end
Expand Down
3 changes: 1 addition & 2 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,8 @@ class User < ApplicationRecord
has_many :enrollments
has_many :courses, through: :enrollments

# TODO: We should probably use lms_id over lms_name
def canvas_credentials
lms_credentials.find_by(lms_name: 'canvas')
lms_credentials.find_by(lms_id: Lms.CANVAS_LMS.id)
end

# Returns a Canvas access token that is valid for at least the next few
Expand Down
Loading
Loading