Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -71,7 +71,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
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
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
13 changes: 13 additions & 0 deletions app/models/course_settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,25 @@ class CourseSettings < ApplicationRecord
{{course_name}} Staff
LIQUID

VALID_NOTIFICATION_FREQUENCIES = %w[daily weekly].freeze

belongs_to :course

before_validation -> { self.pending_notification_frequency = nil if pending_notification_frequency.blank? }
before_validation -> { self.pending_notification_email = nil if pending_notification_email.blank? }
before_save :ensure_system_user_for_auto_approval
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: /\A[^@\s]+@[^@\s]+\z/ },
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 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
28 changes: 28 additions & 0 deletions app/views/courses/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,34 @@
</div>
</div>

<div class="mb-3 row border-bottom pb-3">
<label for="pending-notification-frequency" class="col-sm-4 col-form-label">Pending Request Notifications</label>
<div class="col-sm-8">
<%= select_tag 'course_settings[pending_notification_frequency]',
options_for_select(
[['No notifications', ''], ['Daily', 'daily'], ['Once weekly (Fridays)', 'weekly']],
@course.course_settings&.pending_notification_frequency
),
class: 'form-select',
id: 'pending-notification-frequency',
data: { action: 'change->course-settings#togglePendingNotificationEmail' } %>
</div>
</div>

<div class="mb-3 row border-bottom pb-3">
<label for="pending-notification-email" class="col-sm-4 col-form-label">Notification Email</label>
<div class="col-sm-8">
<%= email_field_tag 'course_settings[pending_notification_email]',
@course.course_settings&.pending_notification_email,
class: 'form-control',
id: 'pending-notification-email',
placeholder: 'instructor@berkeley.edu',
data: { course_settings_target: 'pendingNotificationEmail' },
disabled: @course.course_settings&.pending_notification_frequency.blank? %>
<div class="form-text">Weekly notifications are sent on Fridays at 5:00 PM PT.</div>
</div>
</div>

<div class="mb-3">
<div class="text-start">
<% if @course.course_settings&.enable_extensions %>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class AddPendingNotificationToCourseSettings < ActiveRecord::Migration[7.2]
def change
safety_assured do
change_table :course_settings, bulk: true do |t|
t.string :pending_notification_frequency, default: nil
t.string :pending_notification_email, default: nil
end
end
end
end
4 changes: 3 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.2].define(version: 2025_10_01_192900) do
ActiveRecord::Schema[7.2].define(version: 2026_04_06_175234) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"

Expand Down Expand Up @@ -102,6 +102,8 @@
t.boolean "enable_slack_webhook_url"
t.boolean "enable_gradescope", default: false
t.string "gradescope_course_url"
t.string "pending_notification_frequency"
t.string "pending_notification_email"
t.index ["course_id"], name: "index_course_settings_on_course_id"
end

Expand Down
9 changes: 9 additions & 0 deletions lib/tasks/notifications.rake
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace :notifications do
desc 'Send pending request digest emails (usage: rake notifications:send_pending_digests[daily])'
task :send_pending_digests, [:frequency] => :environment do |_t, args|
frequency = args[:frequency]
abort 'Usage: rake notifications:send_pending_digests[daily|weekly]' unless %w[daily weekly].include?(frequency)

PendingRequestsNotificationJob.perform_now(frequency)
end
end
69 changes: 69 additions & 0 deletions spec/controllers/course_settings_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,75 @@
end
end

describe 'pending notification params' do
before do
session[:user_id] = instructor.canvas_uid
UserToCourse.create!(user: instructor, course: course, role: 'instructor')
allow_any_instance_of(Course).to receive(:user_role).with(instructor).and_return('instructor')
CourseSettings.create!(course: course, enable_extensions: true)
end

it 'persists pending notification settings' do
post :update, params: {
course_id: course.id,
course_settings: {
pending_notification_frequency: 'daily',
pending_notification_email: 'prof@berkeley.edu'
},
tab: 'general'
}

settings = CourseSettings.find_by(course_id: course.id)
expect(settings.pending_notification_frequency).to eq('daily')
expect(settings.pending_notification_email).to eq('prof@berkeley.edu')
end

it 'normalizes blank frequency to nil' do
post :update, params: {
course_id: course.id,
course_settings: {
pending_notification_frequency: '',
pending_notification_email: ''
},
tab: 'general'
}

settings = CourseSettings.find_by(course_id: course.id)
expect(settings.pending_notification_frequency).to be_nil
end

it 'clears stored email when frequency is set to blank' do
settings = CourseSettings.find_by(course_id: course.id)
settings.update!(pending_notification_frequency: 'daily', pending_notification_email: 'prof@berkeley.edu')

post :update, params: {
course_id: course.id,
course_settings: {
pending_notification_frequency: '',
pending_notification_email: ''
},
tab: 'general'
}

settings.reload
expect(settings.pending_notification_frequency).to be_nil
expect(settings.pending_notification_email).to be_nil
end

it 'shows validation errors for invalid email with frequency set' do
post :update, params: {
course_id: course.id,
course_settings: {
pending_notification_frequency: 'daily',
pending_notification_email: 'not-an-email'
},
tab: 'general'
}

expect(flash[:alert]).to include('Failed to update course settings:')
end
end

describe 'pending requests count' do
let(:assignment) do
# Create necessary related objects for Request
Expand Down
10 changes: 10 additions & 0 deletions spec/factories/course_settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,15 @@
association :course
enable_extensions { true }
auto_approve_days { 0 }

trait :with_daily_notifications do
pending_notification_frequency { 'daily' }
pending_notification_email { 'instructor@example.com' }
end

trait :with_weekly_notifications do
pending_notification_frequency { 'weekly' }
pending_notification_email { 'instructor@example.com' }
end
end
end
86 changes: 86 additions & 0 deletions spec/jobs/pending_requests_notification_job_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
require 'rails_helper'

RSpec.describe PendingRequestsNotificationJob, type: :job do
let(:course) { create(:course, canvas_id: 'notif_123', course_name: 'CS 101', course_code: 'CS101') }
let(:student) { create(:user, canvas_uid: 'stu_notif_1', email: 'student_notif@example.com', name: 'Student') }
let(:lms) { Lms.first }
let(:course_to_lms) { CourseToLms.create!(course: course, lms: lms, external_course_id: 'ext_123') }
let(:assignment) do
Assignment.create!(
name: 'HW1',
course_to_lms: course_to_lms,
due_date: 3.days.from_now,
external_assignment_id: 'asgn_notif_1',
enabled: true
)
end

before do
ActionMailer::Base.delivery_method = :test
ActionMailer::Base.deliveries.clear
allow(ENV).to receive(:fetch).and_call_original
allow(ENV).to receive(:fetch).with('DEFAULT_FROM_EMAIL').and_return('flextensions@berkeley.edu')
allow(ENV).to receive(:fetch).with('APP_HOST', nil).and_return('http://localhost:3000')
end

describe '#perform' do
it 'sends email when course has matching frequency and pending requests' do
course.course_settings.update!(pending_notification_frequency: 'daily', pending_notification_email: 'prof@example.com')
Request.create!(course: course, assignment: assignment, user: student, status: 'pending',
reason: 'Need more time', requested_due_date: 5.days.from_now)

expect { described_class.perform_now('daily') }.to change { ActionMailer::Base.deliveries.count }.by(1)

mail = ActionMailer::Base.deliveries.last
expect(mail.to).to eq(['prof@example.com'])
expect(mail.subject).to include('1 Pending Extension Request')
expect(mail.subject).to include('CS101')
expect(mail.body.encoded).to include("http://localhost:3000/courses/#{course.id}/requests")
end

it 'skips courses with zero pending requests' do
course.course_settings.update!(pending_notification_frequency: 'daily', pending_notification_email: 'prof@example.com')

expect { described_class.perform_now('daily') }.not_to(change { ActionMailer::Base.deliveries.count })
end

it 'only sends to courses matching the given frequency' do
course.course_settings.update!(pending_notification_frequency: 'weekly', pending_notification_email: 'prof@example.com')
Request.create!(course: course, assignment: assignment, user: student, status: 'pending',
reason: 'Need more time', requested_due_date: 5.days.from_now)

expect { described_class.perform_now('daily') }.not_to(change { ActionMailer::Base.deliveries.count })
end

it 'pluralizes correctly for multiple pending requests' do
course.course_settings.update!(pending_notification_frequency: 'daily', pending_notification_email: 'prof@example.com')
2.times do |i|
Request.create!(course: course, assignment: assignment,
user: create(:user, canvas_uid: "stu_multi_#{i}", email: "stu_multi_#{i}@example.com"),
status: 'pending', reason: 'Need time', requested_due_date: 5.days.from_now)
end

described_class.perform_now('daily')

mail = ActionMailer::Base.deliveries.last
expect(mail.subject).to include('2 Pending Extension Requests')
end

it 'sends separate emails to multiple courses' do
course.course_settings.update!(pending_notification_frequency: 'daily', pending_notification_email: 'prof1@example.com')
Request.create!(course: course, assignment: assignment, user: student, status: 'pending',
reason: 'Need time', requested_due_date: 5.days.from_now)

other_course = create(:course, canvas_id: 'notif_456', course_name: 'CS 201', course_code: 'CS201')
other_ctlms = CourseToLms.create!(course: other_course, lms: lms, external_course_id: 'ext_456')
other_assignment = Assignment.create!(name: 'HW2', course_to_lms: other_ctlms, due_date: 3.days.from_now,
external_assignment_id: 'asgn_notif_2', enabled: true)
other_course.course_settings.update!(pending_notification_frequency: 'daily', pending_notification_email: 'prof2@example.com')
other_student = create(:user, canvas_uid: 'stu_notif_2', email: 'stu_notif_2@example.com')
Request.create!(course: other_course, assignment: other_assignment, user: other_student, status: 'pending',
reason: 'Need time', requested_due_date: 5.days.from_now)

expect { described_class.perform_now('daily') }.to change { ActionMailer::Base.deliveries.count }.by(2)
end
end
end
Loading