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
59 changes: 16 additions & 43 deletions app/controllers/course_settings_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,57 +4,36 @@ class CourseSettingsController < ApplicationController
before_action :set_course
before_action :require_course_staff!
before_action :set_pending_request_count
before_action :set_course_settings

# Default template settings
DEFAULT_EMAIL_SUBJECT = 'Extension Request Status: {{status}} - {{course_code}}'.freeze
DEFAULT_EMAIL_TEMPLATE = "Dear {{student_name}},\n\n
Your extension request for {{assignment_name}} in {{course_name}} ({{course_code}}) has been {{status}}.
\n\nExtension Details:
\n- Original Due Date: {{original_due_date}}
\n- New Due Date: {{new_due_date}}
\n- Extension Days: {{extension_days}}
\n\nIf you have any questions, please contact the course staff.
\n\nBest regards,
\n{{course_name}} Staff".freeze
def approvals
end

# rubocop:disable Metrics/AbcSize
def update
@side_nav = 'course_settings'
@course_settings = @course.course_settings
def emails
end

def update
if params[:reset_email_template].present?
reset_email_templates
redirect_to course_settings_path(@course, tab: 'email'), notice: 'Email templates reset to defaults.'
redirect_back_or_to emails_course_settings_path(@course), notice: 'Email templates reset to defaults.'
elsif @course_settings.update(course_settings_params)
if @course_settings.enable_slack_webhook_url &&
@course_settings.slack_webhook_url.present? &&
@course_settings.saved_change_to_slack_webhook_url?

success = SlackNotifier.notify(
":wave: Slack notifications have been enabled for *#{@course.course_name}* (#{@course.course_code}). You will now receive updates here!",
@course_settings.slack_webhook_url
)
unless success
redirect_to course_settings_path(@course, tab: params[:tab]), alert: 'Failed to send Slack notification. Please check the webhook URL.'
return
end
redirect_to course_settings_path(@course, tab: params[:tab]), notice: 'Course settings updated successfully. Check your Slack channel for Notifications.'
return
end
redirect_to course_settings_path(@course, tab: params[:tab]), notice: 'Course settings updated successfully.'
redirect_back_or_to approvals_course_settings_path(@course), notice: 'Course settings updated successfully.'
else
flash[:alert] = "Failed to update course settings: #{@course_settings.errors.full_messages.to_sentence}"
redirect_to course_settings_path(@course, tab: params[:tab])
redirect_back_or_to approvals_course_settings_path(@course)
end
end
# rubocop:enable Metrics/AbcSize

private

def set_course_settings
@course_settings = @course.course_settings || @course.build_course_settings
end

def reset_email_templates
@course_settings.update(
email_subject: DEFAULT_EMAIL_SUBJECT,
email_template: DEFAULT_EMAIL_TEMPLATE
email_subject: CourseSettings::DEFAULT_EMAIL_SUBJECT,
email_template: CourseSettings::DEFAULT_EMAIL_TEMPLATE
)
end

Expand All @@ -67,15 +46,9 @@ def course_settings_params
:max_auto_approve,
:enable_min_hours_before_deadline,
:min_hours_before_deadline,
:enable_gradescope,
:gradescope_course_url,
:extend_late_due_date,
:enable_emails,
:reply_email,
:email_subject,
:email_template,
:enable_slack_webhook_url,
:slack_webhook_url
:email_template
]
)
end
Expand Down
66 changes: 65 additions & 1 deletion app/controllers/courses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ def show
# TODO: This shouldn't be possible. Remove?
return redirect_to courses_path, alert: 'No Canvas LMS data found for this course.' unless @course.has_canvas_linked?

@side_nav = 'show'
@course.regenerate_readonly_api_token_if_blank

if @course.staff_user?(current_user)
Expand Down Expand Up @@ -71,6 +70,24 @@ def create
redirect_to courses_path, notice: 'Selected courses and their assignments have been imported successfully.'
end

def update
@course_settings = @course.course_settings || @course.build_course_settings

attrs = course_params.to_h
# Only overwrite the semester when both dropdowns are set; this preserves a
# value stored in an unexpected format that the picker left blank.
semester = combined_semester
attrs[:semester] = semester if semester.present?

if @course.update(attrs) && @course_settings.update(course_settings_params)
after_course_details_saved
else
errors = (@course.errors.full_messages + @course_settings.errors.full_messages).to_sentence
flash.now[:alert] = "Failed to update course details: #{errors}"
render :edit, status: :unprocessable_content
end
end

def sync_assignments
return render json: { error: 'Course not found.' }, status: :not_found unless @course

Expand Down Expand Up @@ -110,6 +127,53 @@ def delete

private

def require_course_staff
return if @course.course_staff?(@user)

redirect_to course_path(@course.id), alert: 'You do not have access to this page.'
end

def course_params
params.require(:course).permit(:course_name, :course_code, :demo_course)
end

# Course-level settings edited alongside the course itself on Course Details.
def course_settings_params
params.fetch(:course_settings, {}).permit(
:enable_extensions,
:enable_gradescope,
:gradescope_course_url,
:enable_emails,
:reply_email,
:enable_slack_webhook_url,
:slack_webhook_url
)
end

# Redirects after a successful save, sending a Slack ping when the webhook
# was just enabled.
def after_course_details_saved
unless @course_settings.slack_webhook_just_enabled?
return redirect_to edit_course_path(@course), notice: 'Course details updated successfully.'
end

if SlackNotifier.notify(@course_settings.slack_enabled_message, @course_settings.slack_webhook_url)
redirect_to edit_course_path(@course), notice: 'Course details updated successfully. Check your Slack channel for notifications.'
else
redirect_to edit_course_path(@course), alert: 'Failed to send Slack notification. Please check the webhook URL.'
end
end

# Combines the season + year dropdowns into a "Season Year" string, or nil
# when either is blank.
def combined_semester
season = params.dig(:course, :semester_season)
year = params.dig(:course, :semester_year)
return nil if season.blank? || year.blank?

"#{season} #{year}"
end

# Returns the time the roster was last synced from Canvas, or nil if never synced.
def enrollments_last_synced_at
synced_at = @course.course_to_lms&.recent_roster_sync&.dig('synced_at')
Expand Down
2 changes: 0 additions & 2 deletions app/controllers/form_settings_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,10 @@ class FormSettingsController < ApplicationController
before_action :set_pending_request_count

def edit
@side_nav = 'form_settings'
@form_setting = @course.form_setting
end

def update
@side_nav = 'form_settings'
@form_setting = @course.form_setting || @course.build_form_setting

permitted = form_setting_params.to_h
Expand Down
3 changes: 0 additions & 3 deletions app/controllers/requests_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ class RequestsController < ApplicationController
before_action :ensure_request_is_pending, only: %i[update approve reject]

def index
@side_nav = 'requests'
if @course.staff_user?(current_user)
scope = @course.requests.includes(:assignment)
@requests = params[:show_all] == 'true' ? scope : scope.pending
Expand All @@ -32,7 +31,6 @@ def show
end

def new
@side_nav = 'form'
return redirect_to courses_path, alert: 'No Canvas LMS data found for this course.' unless @course.has_canvas_linked?

return new_for_student if @course.staff_user?(current_user)
Expand Down Expand Up @@ -149,7 +147,6 @@ def mass_reject
# their own requests; anything outside the caller's scope is reported as
# "not found" rather than leaking that it exists.
def set_request
@side_nav = 'requests'
@request = requests_visible_to_user.includes(:assignment).find_by(id: params[:id])
redirect_to course_path(@course), alert: 'Request not found.' unless @request
end
Expand Down
40 changes: 40 additions & 0 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,44 @@
module ApplicationHelper
# Identifies which sidebar entry is active for the current request, derived
# from the controller and action rather than a per-action @side_nav ivar.
def current_nav_page
case "#{controller_name}##{action_name}"
when 'courses#show'
'show'
when 'courses#edit', 'courses#update'
'course_details'
when 'courses#enrollments'
'enrollments'
when 'course_settings#approvals'
'approvals'
when 'course_settings#emails'
'emails'
when 'form_settings#edit', 'form_settings#update'
'form_settings'
when 'requests#new', 'requests#new_for_student'
'form'
when /\Arequests#/
'requests'
end
end

# Renders a sidebar link. Pass the nav key it represents and it highlights
# itself when that is the current page. The label can be given as `text:` or,
# for richer content like the requests badge, as a block.
def sidebar_nav_item(path:, icon:, nav:, text: nil, &block)
active = current_nav_page == nav
label = block ? capture(&block) : text

tag.li class: 'nav-item p-2' do
link_to path, class: "nav-link d-flex align-items-center #{active ? 'active' : 'link-body-emphasis'}" do
safe_join([
tag.div(tag.i('', class: "#{icon} fa-fw me-3"), class: 'sidebar-icon-container ms-3'),
tag.span(label, class: 'nav-text ms-2')
])
end
end
end

def assignment_link_for(assignment, course)
case assignment.course_to_lms.lms_id
when 1
Expand Down
14 changes: 1 addition & 13 deletions app/javascript/controllers/course_settings_controller.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Controller } from "@hotwired/stimulus"

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

connect() {
this.toggleEmailFields();
Expand Down Expand Up @@ -52,16 +52,4 @@ export default class extends Controller {
slackWebhookField.disabled = !slackToggle.checked;
}
}

updateUrlParam(event) {
const tabName = event.currentTarget.dataset.tab;
const url = new URL(window.location);
url.searchParams.set('tab', tabName);
window.history.pushState({}, '', url);

const tabInput = document.getElementById('tab');
if (tabInput) {
tabInput.value = tabName;
}
}
}
22 changes: 22 additions & 0 deletions app/models/course.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,28 @@ def self.sort_semesters(semesters)
semesters.sort_by { |s| semester_sort_key(s) }.reverse
end

# Seasons offered in the Course Details semester picker.
SEMESTER_SEASONS = %w[Winter Spring Summer Fall].freeze
# Earliest selectable academic year in the semester picker.
FIRST_SEMESTER_YEAR = 2012

# Years offered in the semester picker: FIRST_SEMESTER_YEAR through next year.
def self.semester_year_options(today = Date.current)
(FIRST_SEMESTER_YEAR..today.year + 1).to_a
end

# Splits a stored semester string (e.g. "Spring 2026") into [season, year]
# when it is a recognized season paired with an in-range year, otherwise
# [nil, nil]. This lets the Course Details form pre-select valid values and
# leave the dropdowns empty for anything stored in an unexpected format.
def self.parse_semester(semester)
season, year = semester.to_s.split
return [ nil, nil ] unless SEMESTER_SEASONS.include?(season)
return [ nil, nil ] unless year&.match?(/\A\d{4}\z/) && semester_year_options.include?(year.to_i)

[ season, year.to_i ]
end

# Month a term starts in maps to its Berkeley season.
# Spring starts in January, Summer in late May, Fall in late August.
SEASON_BY_START_MONTH = {
Expand Down
22 changes: 17 additions & 5 deletions app/models/course_settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@

class CourseSettings < ApplicationRecord
# TODO: Remove the db default text, and use an AR validation.
DEFAULT_EMAIL_TEMPLATE = <<~LIQUID.freeze
Hello {{student_name}},
DEFAULT_EMAIL_SUBJECT = 'Extension Request Status: {{status}} - {{course_code}}'.freeze
DEFAULT_EMAIL_TEMPLATE = <<~TEMPLATE.freeze
Dear {{student_name}},

Your extension request for {{assignment_name}} in {{course_name}} ({{course_code}}) has been {{status}}.

Expand All @@ -45,11 +46,11 @@ class CourseSettings < ApplicationRecord
- New Due Date: {{new_due_date}}
- Extension Days: {{extension_days}}

If you have any questions, please reach out to your course staff.
If you have any questions, please contact the course staff.

Thank you,
Best regards,
{{course_name}} Staff
LIQUID
TEMPLATE

belongs_to :course

Expand All @@ -67,6 +68,17 @@ def automatic_approval_enabled?
auto_approve_days.positive? || auto_approve_extended_request_days.positive?
end

# True when this save just turned on the Slack webhook, so callers know to
# send a confirmation ping.
def slack_webhook_just_enabled?
enable_slack_webhook_url && slack_webhook_url.present? && saved_change_to_slack_webhook_url?
end

def slack_enabled_message
":wave: Slack notifications have been enabled for *#{course.course_name}* " \
"(#{course.course_code}). You will now receive updates here!"
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
Loading
Loading