diff --git a/app/controllers/course_settings_controller.rb b/app/controllers/course_settings_controller.rb index 08263b99..2fadf4a4 100644 --- a/app/controllers/course_settings_controller.rb +++ b/app/controllers/course_settings_controller.rb @@ -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 @@ -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 diff --git a/app/controllers/courses_controller.rb b/app/controllers/courses_controller.rb index ede42f1e..8e56a6c2 100644 --- a/app/controllers/courses_controller.rb +++ b/app/controllers/courses_controller.rb @@ -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) @@ -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 @@ -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') diff --git a/app/controllers/form_settings_controller.rb b/app/controllers/form_settings_controller.rb index 0195a415..58ceac59 100644 --- a/app/controllers/form_settings_controller.rb +++ b/app/controllers/form_settings_controller.rb @@ -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 diff --git a/app/controllers/requests_controller.rb b/app/controllers/requests_controller.rb index d627d9c3..b4f78a00 100644 --- a/app/controllers/requests_controller.rb +++ b/app/controllers/requests_controller.rb @@ -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 @@ -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) @@ -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 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index fc42c407..2aad5c59 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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 diff --git a/app/javascript/controllers/course_settings_controller.js b/app/javascript/controllers/course_settings_controller.js index 19a4ff03..bcc037a9 100644 --- a/app/javascript/controllers/course_settings_controller.js +++ b/app/javascript/controllers/course_settings_controller.js @@ -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(); @@ -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; - } - } } diff --git a/app/models/course.rb b/app/models/course.rb index 6aafc843..4e5d0635 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -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 = { diff --git a/app/models/course_settings.rb b/app/models/course_settings.rb index c2b1524b..49694570 100644 --- a/app/models/course_settings.rb +++ b/app/models/course_settings.rb @@ -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}}. @@ -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 @@ -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? diff --git a/app/views/course_settings/approvals.html.erb b/app/views/course_settings/approvals.html.erb new file mode 100644 index 00000000..583215d9 --- /dev/null +++ b/app/views/course_settings/approvals.html.erb @@ -0,0 +1,112 @@ +
0 still requires the deadline to not have passed. Recommended values are 0 or 24.
+
+ Student Information:
+{{student_name}} - Full name{{student_email}} - Email{{student_id}} - ID numberCourse Information:
+{{course_name}} - Course name{{course_code}} - Course code{{assignment_name}} - AssignmentExtension Information:
+{{original_due_date}} - Original due date{{new_due_date}} - New due date{{extension_days}} - Days extended{{status}} - Status (Approved/Denied)0 still requires the deadline to not have passed. Recommended values are 0 or 24.
-
- <%= ENV.fetch('GRADESCOPE_EMAIL') { 'gradescope-bot@berkeley.edu' } %>
- as a TA to your Gradescope course for integration to work.
-
-
+ <%= ENV.fetch('GRADESCOPE_EMAIL') { 'gradescope-bot@berkeley.edu' } %>
+ as a TA to your Gradescope course for integration to work.
+
+
Student Information:
-{{student_name}} - Full name{{student_email}} - Email{{student_id}} - ID numberCourse Information:
-{{course_name}} - Course name{{course_code}} - Course code{{assignment_name}} - AssignmentExtension Information:
-{{original_due_date}} - Original due date{{new_due_date}} - New due date{{extension_days}} - Days extended{{status}} - Status (Approved/Denied)