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 @@ +
+
+
+ <%= render "courses/course_title" %> +

Automatic Approvals

+ <%= form_with url: course_settings_path(@course), method: :patch do |form| %> +
+
+

Automatic Approvals

+
+
+
+ +
+ <%= number_field_tag 'course_settings[auto_approve_days]', + @course.course_settings&.auto_approve_days, + min: 0, + step: 1, + class: 'form-control', + id: 'auto-approve', + placeholder: 'Number of days' %> + Extensions requested within this many days will be automatically approved, subject to the maximum limit below. +
+
+ +
+ +
+ <%= number_field_tag 'course_settings[auto_approve_extended_request_days]', + @course.course_settings&.auto_approve_extended_request_days, + min: 0, + step: 1, + class: 'form-control', + id: 'auto-approve-dsp', + placeholder: 'Number of days' %> + + This allows students who have 'Allow Extended Requests' checked to have requests automatically approved within an extended timeframe. Use this for students who have extenuating circumstances. + +
+
+ +
+ +
+ <%= number_field_tag 'course_settings[max_auto_approve]', + @course.course_settings&.max_auto_approve, + min: 0, + step: 1, + class: 'form-control', + id: 'max-auto-approve', + placeholder: 'Number' %> + This limit applies per student per course. Set to zero for no limit on auto-approved requests. +
+
+ + +
+
+ <%= hidden_field_tag 'course_settings[enable_min_hours_before_deadline]', false %> + <%= check_box_tag 'course_settings[enable_min_hours_before_deadline]', + true, + @course.course_settings&.enable_min_hours_before_deadline.nil? ? true : @course.course_settings&.enable_min_hours_before_deadline, + class: 'form-check-input', + id: 'enable-min-hours-before-deadline' %> + +
+
+ +
+ +
+ <%= number_field_tag 'course_settings[min_hours_before_deadline]', + @course.course_settings&.min_hours_before_deadline || 0, + min: 0, + step: 1, + class: 'form-control', + id: 'min-hours-before-deadline', + placeholder: 'Number of hours' %> + + When enabled, a request is only auto-approved if it is made at least this many hours before the assignment's deadline; otherwise it is held for manual review. + A value of 0 still requires the deadline to not have passed. Recommended values are 0 or 24. + +
+
+ +
+
+ <%= hidden_field_tag 'course_settings[extend_late_due_date]', false %> + <%= check_box_tag 'course_settings[extend_late_due_date]', + true, + @course.course_settings&.extend_late_due_date.nil? ? true : @course.course_settings&.extend_late_due_date, + class: 'form-check-input', + id: 'extend-late-due-date' %> + +
+ + When enabled, the late due date (if present) will be shifted by the same amount as the extension. + When disabled, the new late due date will be set to the later of the original late due date and the extended due date. + +
+
+
+ +
+ <%= form.submit 'Save Settings', class: 'btn btn-success px-4' %> +
+ <% end %> +
+
+
diff --git a/app/views/course_settings/emails.html.erb b/app/views/course_settings/emails.html.erb new file mode 100644 index 00000000..43526d8f --- /dev/null +++ b/app/views/course_settings/emails.html.erb @@ -0,0 +1,76 @@ +
+
+
+ <%= render "courses/course_title" %> +

Email Templates

+ <%= form_with url: course_settings_path(@course), method: :patch do |form| %> +
+
+

Email Template

+ <%= button_tag "Reset to Default", + name: "reset_email_template", + value: "true", + class: "btn btn-secondary btn-sm", + type: "submit" %> +
+
+
+ + <%= text_field_tag 'course_settings[email_subject]', + @course.course_settings&.email_subject || CourseSettings::DEFAULT_EMAIL_SUBJECT, + class: 'form-control', + id: 'email-subject' %> + Use {{variable_name}} to insert dynamic values +
+ +
+ + <%= text_area_tag 'course_settings[email_template]', + @course.course_settings&.email_template || CourseSettings::DEFAULT_EMAIL_TEMPLATE, + class: 'form-control', + id: 'email-template', + rows: 10 %> +
+ +
+
+

Available Variables

+
+
+

Student Information:

+
    +
  • {{student_name}} - Full name
  • +
  • {{student_email}} - Email
  • +
  • {{student_id}} - ID number
  • +
+
+
+

Course Information:

+
    +
  • {{course_name}} - Course name
  • +
  • {{course_code}} - Course code
  • +
  • {{assignment_name}} - Assignment
  • +
+
+
+

Extension Information:

+
    +
  • {{original_due_date}} - Original due date
  • +
  • {{new_due_date}} - New due date
  • +
  • {{extension_days}} - Days extended
  • +
  • {{status}} - Status (Approved/Denied)
  • +
+
+
+
+
+
+
+ +
+ <%= form.submit 'Save Settings', class: 'btn btn-success px-4' %> +
+ <% end %> +
+
+
diff --git a/app/views/courses/edit.html.erb b/app/views/courses/edit.html.erb index e1854a52..80fa7d7c 100644 --- a/app/views/courses/edit.html.erb +++ b/app/views/courses/edit.html.erb @@ -1,333 +1,210 @@ +<% selected_season, selected_year = Course.parse_semester(@course.semester) %>
<%= render "courses/course_title" %> -

Course Settings

- - <%= form_with url: course_settings_update_path, method: :post do |form| %> - <%= hidden_field_tag :course_id, @course.id %> - <%= hidden_field_tag :tab, params[:tab] || 'general' %> -
-
-
-
-

General Settings

+

Course Details

+ <%= form_with model: @course, url: course_path(@course), method: :patch do |form| %> +
+
+

Course Details

+
+
+
+ +
+ <%= form.text_field :course_name, + class: 'form-control', + id: 'course-name', + placeholder: 'e.g. Introduction to Computer Science' %>
-
-
-
- <%= hidden_field_tag 'course_settings[enable_extensions]', false %> - <%= check_box_tag 'course_settings[enable_extensions]', - true, - @course.course_settings.enable_extensions, - class: 'form-check-input', - id: 'course-enable' %> - -
-
- -
- -
- <%= number_field_tag 'course_settings[auto_approve_days]', - @course.course_settings.auto_approve_days, - min: 0, - step: 1, - class: 'form-control', - id: 'auto-approve', - placeholder: 'Number of days' %> - Extensions requested within this many days will be automatically approved, subject to the maximum limit below. -
-
- -
- -
- <%= number_field_tag 'course_settings[auto_approve_extended_request_days]', - @course.course_settings.auto_approve_extended_request_days, - min: 0, - step: 1, - class: 'form-control', - id: 'auto-approve-dsp', - placeholder: 'Number of days' %> - - This allows students who have 'Allow Extended Requests' checked to have requests automatically approved within an extended timeframe. Use this for students who have extenuating circumstances. - -
-
- -
- -
- <%= number_field_tag 'course_settings[max_auto_approve]', - @course.course_settings.max_auto_approve, - min: 0, - step: 1, - class: 'form-control', - id: 'max-auto-approve', - placeholder: 'Number' %> - This limit applies per student per course. Set to zero for no limit on auto-approved requests. -
-
- - -
-
- <%= hidden_field_tag 'course_settings[enable_min_hours_before_deadline]', false %> - <%= check_box_tag 'course_settings[enable_min_hours_before_deadline]', - true, - @course.course_settings.enable_min_hours_before_deadline, - class: 'form-check-input', - id: 'enable-min-hours-before-deadline' %> - -
-
- -
- -
- <%= number_field_tag 'course_settings[min_hours_before_deadline]', - @course.course_settings.min_hours_before_deadline, - min: 0, - step: 1, - class: 'form-control', - id: 'min-hours-before-deadline', - placeholder: 'Number of hours' %> - - When enabled, a request is only auto-approved if it is made at least this many hours before the assignment's deadline; otherwise it is held for manual review. - A value of 0 still requires the deadline to not have passed. Recommended values are 0 or 24. - -
-
- -
-
- <%= hidden_field_tag 'course_settings[extend_late_due_date]', false %> - <%= check_box_tag 'course_settings[extend_late_due_date]', - true, - @course.course_settings.extend_late_due_date, - class: 'form-check-input', - id: 'extend-late-due-date' %> - -
- - When enabled, the late due date (if present) will be shifted by the same amount as the extension. - When disabled, the new late due date will be set to the later of the original late due date and the extended due date. - -
- -
-
- <%= hidden_field_tag 'course_settings[enable_gradescope]', false %> - <%= check_box_tag 'course_settings[enable_gradescope]', - true, - @course.course_settings.enable_gradescope, - class: 'form-check-input', - id: 'enable-gradescope' %> - -
-
+
-
- -
- <%= url_field_tag 'course_settings[gradescope_course_url]', - @course.course_settings.gradescope_course_url, - class: 'form-control', - id: 'gradescope-course-url', - placeholder: 'https://www.gradescope.com/courses/123456', - data: { course_settings_target: "gradescopeField" }, - disabled: !@course.course_settings.enable_gradescope, - pattern: 'https://(www\.)?gradescope\.com/courses/\d+/?', - title: 'Must be a valid Gradescope course URL (e.g. https://www.gradescope.com/courses/123456)' %> - - Please invite the user <%= ENV.fetch('GRADESCOPE_EMAIL') { 'gradescope-bot@berkeley.edu' } %> - as a TA to your Gradescope course for integration to work. - - +
+ +
+ <%= form.text_field :course_code, + class: 'form-control', + id: 'course-code', + placeholder: 'e.g. COMPSCI 61A' %> +
+
+
+ +
+
+
+ <%= select_tag 'course[semester_season]', + options_for_select([ [ 'Season', '' ] ] + Course::SEMESTER_SEASONS.map { |s| [ s, s ] }, selected_season), + class: 'form-select', + id: 'semester-season', + 'aria-label': 'Semester season' %>
-
- -
-
- <%= hidden_field_tag 'course_settings[enable_emails]', false %> - <%= check_box_tag 'course_settings[enable_emails]', - true, - @course.course_settings.enable_emails, - class: 'form-check-input', - id: 'enable-email' %> - +
+ <%= select_tag 'course[semester_year]', + options_for_select([ [ 'Year', '' ] ] + Course.semester_year_options.reverse.map { |y| [ y, y ] }, selected_year), + class: 'form-select', + id: 'semester-year', + 'aria-label': 'Semester year' %>
+ Current semester: <%= @course.semester.presence || 'Unknown Semester' %> +
+
-
- -
- <%= email_field_tag 'course_settings[reply_email]', - @course.course_settings.reply_email, - class: 'form-control', - id: 'reply-email', - placeholder: 'example@example.com', - data: { course_settings_target: "emailField" }, - disabled: !@course.course_settings.enable_emails %> -
+
+
+
+ <%= form.check_box :demo_course, class: 'form-check-input', id: 'demo-course' %> +
+ Marks this as a demo/sandbox course so it can be excluded from usage metrics. +
+
+
+
-
-
- <%= hidden_field_tag 'course_settings[enable_slack_webhook_url]', false %> - <%= check_box_tag 'course_settings[enable_slack_webhook_url]', - true, - @course.course_settings.enable_slack_webhook_url, - class: 'form-check-input', - id: 'enable-slack' %> - -
-
+
+
+

Extensions

+
+
+
+ <%= hidden_field_tag 'course_settings[enable_extensions]', false %> + <%= check_box_tag 'course_settings[enable_extensions]', + true, + @course.course_settings&.enable_extensions, + class: 'form-check-input', + id: 'course-enable' %> + +
+
+
-
- -
- <%= text_field_tag 'course_settings[slack_webhook_url]', - @course.course_settings.slack_webhook_url, - class: 'form-control', - id: 'slack-webhook', - placeholder: 'https://hooks.slack.com/services/...', - data: { course_settings_target: "slackWebhookField" }, - disabled: !@course.course_settings.enable_slack_webhook_url %> -
-
+
+
+

Gradescope

+
+
+
+
+ <%= hidden_field_tag 'course_settings[enable_gradescope]', false %> + <%= check_box_tag 'course_settings[enable_gradescope]', + true, + @course.course_settings&.enable_gradescope, + class: 'form-check-input', + id: 'enable-gradescope' %> + +
+
-
-
- <% if @course.requests_enabled? %> - <%= link_to 'Delete Course (must have student extension requests disabled)', '#', class: 'btn btn-outline-danger disable me-3', aria_disabled: 'true' %> - <% else %> - <%= link_to 'Delete Course', - delete_course_path, - method: :delete, - class: 'btn btn-danger me-3', - data: { - confirm: 'Are you sure you want to delete this course? This action cannot be undone.' - } %> - <% end %> -
-
+
+ +
+ <%= url_field_tag 'course_settings[gradescope_course_url]', + @course.course_settings&.gradescope_course_url, + class: 'form-control', + id: 'gradescope-course-url', + placeholder: 'https://www.gradescope.com/courses/123456', + data: { course_settings_target: "gradescopeField" }, + disabled: !@course.course_settings&.enable_gradescope, + pattern: 'https://(www\.)?gradescope\.com/courses/\d+/?', + title: 'Must be a valid Gradescope course URL (e.g. https://www.gradescope.com/courses/123456)' %> + + Please invite the user <%= ENV.fetch('GRADESCOPE_EMAIL') { 'gradescope-bot@berkeley.edu' } %> + as a TA to your Gradescope course for integration to work. + +
+
- -
-
-
-

Email Template

- <%= button_tag "Reset to Default", - name: "reset_email_template", - value: "true", - class: "btn btn-secondary btn-sm", - type: "submit" %> +
+
+

Notifications

+
+
+
+
+ <%= hidden_field_tag 'course_settings[enable_emails]', false %> + <%= check_box_tag 'course_settings[enable_emails]', + true, + @course.course_settings&.enable_emails, + class: 'form-check-input', + id: 'enable-email' %> +
-
-
- - <%= text_field_tag 'course_settings[email_subject]', - @course.course_settings.email_subject, - class: 'form-control', - id: 'email-subject' %> - Use {{variable_name}} to insert dynamic values -
+
-
- - <%= text_area_tag 'course_settings[email_template]', - @course.course_settings.email_template, - class: 'form-control', - id: 'email-template', - rows: 10 %> -
+
+ +
+ <%= email_field_tag 'course_settings[reply_email]', + @course.course_settings&.reply_email, + class: 'form-control', + id: 'reply-email', + placeholder: 'example@example.com', + data: { course_settings_target: "emailField" }, + disabled: !@course.course_settings&.enable_emails %> +
+
-
-
-

Available Variables

-
-
-

Student Information:

-
    -
  • {{student_name}} - Full name
  • -
  • {{student_email}} - Email
  • -
  • {{student_id}} - ID number
  • -
-
-
-

Course Information:

-
    -
  • {{course_name}} - Course name
  • -
  • {{course_code}} - Course code
  • -
  • {{assignment_name}} - Assignment
  • -
-
-
-

Extension Information:

-
    -
  • {{original_due_date}} - Original due date
  • -
  • {{new_due_date}} - New due date
  • -
  • {{extension_days}} - Days extended
  • -
  • {{status}} - Status (Approved/Denied)
  • -
-
-
-
-
+
+
+ <%= hidden_field_tag 'course_settings[enable_slack_webhook_url]', false %> + <%= check_box_tag 'course_settings[enable_slack_webhook_url]', + true, + @course.course_settings&.enable_slack_webhook_url, + class: 'form-check-input', + id: 'enable-slack' %> + +
+
+ +
+ +
+ <%= text_field_tag 'course_settings[slack_webhook_url]', + @course.course_settings&.slack_webhook_url, + class: 'form-control', + id: 'slack-webhook', + placeholder: 'https://hooks.slack.com/services/...', + data: { course_settings_target: "slackWebhookField" }, + disabled: !@course.course_settings&.enable_slack_webhook_url %>
-
- <%= form.submit 'Save Settings', class: 'btn btn-success px-4' %> +
+
+ <% if @course.course_settings&.enable_extensions %> + <%= link_to 'Delete Course (must have student extension requests disabled)', '#', class: 'btn btn-outline-danger disable', aria_disabled: 'true' %> + <% else %> + <%= link_to 'Delete Course', + delete_course_path(@course), + method: :delete, + class: 'btn btn-danger', + data: { + confirm: 'Are you sure you want to delete this course? This action cannot be undone.' + } %> + <% end %> +
+ <%= form.submit 'Save Course Details', class: 'btn btn-success px-4' %>
<% end %>
diff --git a/app/views/courses/enrollments.html.erb b/app/views/courses/enrollments.html.erb index c830ad2e..61ff1a53 100644 --- a/app/views/courses/enrollments.html.erb +++ b/app/views/courses/enrollments.html.erb @@ -22,7 +22,7 @@ data-bs-toggle="popover" data-bs-trigger="focus" data-bs-html="true" - data-bs-content="Allow students to have an additional window for automatically approved requests, such as for extenuating circumstances. Set the limit from the settings page." + data-bs-content="Allow students to have an additional window for automatically approved requests, such as for extenuating circumstances. Set the limit from the settings page." aria-label="Help: additional request window settings"> diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index 86a492ed..cad2c953 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -15,50 +15,42 @@ <% end %> - <%= render 'layouts/components/sidebar_menu_item', - path: course_path(@course.id), - icon: 'fas fa-tasks', - text: 'Assignments', - active: @side_nav == 'show' %> + <%= sidebar_nav_item path: course_path(@course.id), icon: 'fas fa-tasks', nav: 'show', text: 'Assignments' %> - <%= render 'layouts/components/sidebar_menu_item', - path: course_requests_path(@course.id), - icon: 'fas fa-clock', - text: "Requests #{@pending_requests_count}", - active: @side_nav == 'requests' %> + <%= sidebar_nav_item path: course_requests_path(@course.id), icon: 'fas fa-clock', nav: 'requests' do %> + Requests <%= @pending_requests_count %> + <% end %> <% if @course.staff_user?(current_user) %> - <%= render 'layouts/components/sidebar_menu_item', - path: enrollments_course_path(@course.id), - icon: 'fas fa-users', - text: 'Enrollments', - active: @side_nav == 'enrollments' %> - - <%= render 'layouts/components/sidebar_menu_item', - path: course_settings_path(@course.id), - icon: 'fas fa-cog', - text: 'Settings', - active: @side_nav == 'edit' %> - - <%= render 'layouts/components/sidebar_menu_item', - path: edit_course_form_setting_path(@course.id), - icon: 'fas fa-file-alt', - text: 'Form', - active: @side_nav == 'form_settings' %> - - <%= render 'layouts/components/sidebar_menu_item', - path: new_course_request_path(@course.id), - icon: 'fas fa-user-plus', - text: 'Request for Student', - active: @side_nav == 'form' %> + <%= sidebar_nav_item path: enrollments_course_path(@course.id), icon: 'fas fa-users', nav: 'enrollments', text: 'Enrollments' %> + + <% settings_open = %w[course_details approvals emails form_settings].include?(current_nav_page) %> + + + <%= sidebar_nav_item path: new_course_request_path(@course.id), icon: 'fas fa-user-plus', nav: 'form', text: 'Request for Student' %> <% end %> <% if @course.student_user?(current_user) %> - <%= render 'layouts/components/sidebar_menu_item', - path: new_course_request_path(@course.id), - icon: 'fas fa-file-pen', - text: 'Request Extension', - active: @side_nav == 'form' %> + <%= sidebar_nav_item path: new_course_request_path(@course.id), icon: 'fas fa-file-pen', nav: 'form', text: 'Request Extension' %> <% end %> diff --git a/app/views/layouts/components/_sidebar_menu_item.html.erb b/app/views/layouts/components/_sidebar_menu_item.html.erb deleted file mode 100644 index 6bb94c08..00000000 --- a/app/views/layouts/components/_sidebar_menu_item.html.erb +++ /dev/null @@ -1,8 +0,0 @@ - \ No newline at end of file diff --git a/app/views/shared/_extension_status_warning.html.erb b/app/views/shared/_extension_status_warning.html.erb index c746d43c..3d600dae 100644 --- a/app/views/shared/_extension_status_warning.html.erb +++ b/app/views/shared/_extension_status_warning.html.erb @@ -1,5 +1,5 @@ <% unless course.requests_enabled? %>
- Flextensions is disabled for this course. To allow to students to see this course, please enable extensions in Course Settings. + Flextensions is disabled for this course. To allow to students to see this course, please enable extensions in Course Details.
<% end %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 85d9a2e7..befc6d1e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -25,7 +25,6 @@ get '/courses', to: 'courses#index', as: 'courses' get '/courses/new', to: 'courses#new', as: :new_course get '/courses/:id', to: 'courses#show', as: :course - get '/courses/:id/edit', to: 'courses#edit', as: :course_settings resources :courses do member do @@ -53,9 +52,13 @@ patch :toggle_allow_extended_requests end end - resource :form_setting, only: [:edit, :update] + resource :form_setting, only: [:edit, :update], path: 'settings/form' + + resource :course_settings, only: [:update], controller: :course_settings, as: :settings, path: 'settings' do + get :approvals + get :emails + end end - post 'course_settings/update' resources :assignments do member do diff --git a/db/migrate/20260624000001_add_demo_course_to_courses.rb b/db/migrate/20260624000001_add_demo_course_to_courses.rb new file mode 100644 index 00000000..025372d3 --- /dev/null +++ b/db/migrate/20260624000001_add_demo_course_to_courses.rb @@ -0,0 +1,8 @@ +class AddDemoCourseToCourses < ActiveRecord::Migration[7.2] + def change + # demo_course flags sandbox/demo courses (e.g. the developer-login test + # course). It only helps us track usage so these can be excluded from real + # metrics; it has no effect on application behavior. + add_column :courses, :demo_course, :boolean, default: false, null: false + end +end diff --git a/db/seeds.rb b/db/seeds.rb index 520af0e5..f172fb25 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -32,6 +32,7 @@ test_course = Course.find_or_create_by!(course_code: 'DEV101') do |c| c.course_name = 'Development Test Course' c.canvas_id = 'dev-course-001' + c.demo_course = true end # Link test course to Canvas LMS diff --git a/features/course_settings.feature b/features/course_settings.feature index 7cc4a9f9..e0bd2128 100644 --- a/features/course_settings.feature +++ b/features/course_settings.feature @@ -5,19 +5,19 @@ Feature: Course Settings Scenario: Disabling a course shows flash message Given I'm logged in as a teacher - When I go to the Course Settings page + When I go to the Course Details page And I uncheck "Enable Students to request extensions" - And press "Save Settings" - Then I should see "Course settings updated successfully." + And press "Save Course Details" + Then I should see "Course details updated successfully." And I navigate to Course page Then I should see "Flextensions is disabled for this course. To allow to students to see this course" Scenario: Disabling a course shows flash message for student Given I'm logged in as a teacher - When I go to the Course Settings page + When I go to the Course Details page And I uncheck "Enable Students to request extensions" - And press "Save Settings" - Then I log in as a student + And press "Save Course Details" + Then I log in as a student And I go to the Course page Then I should see "Extensions are not enabled for this course." diff --git a/features/support/paths.rb b/features/support/paths.rb index 5b82fa87..6efc394d 100644 --- a/features/support/paths.rb +++ b/features/support/paths.rb @@ -24,10 +24,13 @@ def path_to(page_name) "courses/#{@course.id}/enrollments" when /^Course Settings page$/ + "/courses/#{@course.id}/settings/approvals" + + when /^Course Details page$/ "/courses/#{@course.id}/edit" when /^Form Settings page$/ - "/courses/#{@course.id}/form_setting/edit" + "/courses/#{@course.id}/settings/form/edit" when /^Requests page$/ "/courses/#{@course.id}/requests" diff --git a/spec/Helpers/application_helper_spec.rb b/spec/Helpers/application_helper_spec.rb new file mode 100644 index 00000000..3262da61 --- /dev/null +++ b/spec/Helpers/application_helper_spec.rb @@ -0,0 +1,51 @@ +require 'rails_helper' + +RSpec.describe ApplicationHelper, type: :helper do + describe '#current_nav_page' do + def section_for(controller, action) + allow(helper).to receive_messages(controller_name: controller, action_name: action) + helper.current_nav_page + end + + it 'maps course and settings pages to their sidebar keys' do + expect(section_for('courses', 'show')).to eq('show') + expect(section_for('courses', 'edit')).to eq('course_details') + expect(section_for('courses', 'update')).to eq('course_details') + expect(section_for('courses', 'enrollments')).to eq('enrollments') + expect(section_for('course_settings', 'approvals')).to eq('approvals') + expect(section_for('course_settings', 'emails')).to eq('emails') + expect(section_for('form_settings', 'edit')).to eq('form_settings') + expect(section_for('form_settings', 'update')).to eq('form_settings') + end + + it 'distinguishes new-request pages from the requests list' do + expect(section_for('requests', 'new')).to eq('form') + expect(section_for('requests', 'new_for_student')).to eq('form') + expect(section_for('requests', 'index')).to eq('requests') + expect(section_for('requests', 'show')).to eq('requests') + end + + it 'returns nil for pages without a sidebar entry' do + expect(section_for('home', 'index')).to be_nil + end + end + + describe '#sidebar_nav_item' do + before { allow(helper).to receive_messages(controller_name: 'courses', action_name: 'show') } + + it 'renders an active link for the current page' do + html = helper.sidebar_nav_item(path: '/x', icon: 'fas fa-tasks', nav: 'show', text: 'Assignments') + + expect(html).to include('nav-link d-flex align-items-center active') + expect(html).to include('fas fa-tasks') + expect(html).to include('Assignments') + end + + it 'renders an inactive link for other pages' do + html = helper.sidebar_nav_item(path: '/x', icon: 'fas fa-users', nav: 'enrollments', text: 'Enrollments') + + expect(html).to include('link-body-emphasis') + expect(html).not_to include('active') + end + end +end diff --git a/spec/controllers/course_settings_controller_spec.rb b/spec/controllers/course_settings_controller_spec.rb index 7c9c453e..f1ce5e1e 100644 --- a/spec/controllers/course_settings_controller_spec.rb +++ b/spec/controllers/course_settings_controller_spec.rb @@ -25,23 +25,22 @@ it 'updates the settings created automatically with the course' do expect(CourseSettings.where(course_id: course.id).count).to eq(1) - post :update, params: { + patch :update, params: { course_id: course.id, course_settings: { - enable_extensions: 'true', auto_approve_days: '3', auto_approve_extended_request_days: '5', - enable_emails: 'true' + max_auto_approve: '2' }, - tab: 'general' + page: 'approvals' } expect(CourseSettings.where(course_id: course.id).count).to eq(1) - expect(response).to redirect_to(course_settings_path(course.id, tab: 'general')) + expect(response).to redirect_to(approvals_course_settings_path(course.id)) expect(flash[:notice]).to eq('Course settings updated successfully.') settings = CourseSettings.find_by(course_id: course.id) - expect(settings.enable_extensions).to be true expect(settings.auto_approve_days).to eq(3) + expect(settings.auto_approve_extended_request_days).to eq(5) end it 'updates existing course settings' do @@ -56,22 +55,20 @@ expect(CourseSettings.where(course_id: course.id).count).to eq(1) - post :update, params: { + patch :update, params: { course_id: course.id, course_settings: { - enable_extensions: 'true', auto_approve_days: '3' }, - tab: 'general' + page: 'approvals' } expect(CourseSettings.where(course_id: course.id).count).to eq(1) # Still only 1 record - expect(response).to redirect_to(course_settings_path(course.id, tab: 'general')) + expect(response).to redirect_to(approvals_course_settings_path(course.id)) expect(flash[:notice]).to eq('Course settings updated successfully.') # Force reload to get updated values course_settings.reload - expect(course_settings.enable_extensions).to be true expect(course_settings.auto_approve_days).to eq(3) end @@ -82,13 +79,13 @@ ) allow_any_instance_of(CourseSettings).to receive(:update).and_return(false) - post :update, params: { + patch :update, params: { course_id: course.id, - course_settings: { enable_extensions: 'true' }, - tab: 'general' + course_settings: { auto_approve_days: '3' }, + page: 'approvals' } - expect(response).to redirect_to(course_settings_path(course.id, tab: 'general')) + expect(response).to redirect_to(approvals_course_settings_path(course.id)) expect(flash[:alert]).to include('Failed to update course settings:') end @@ -99,17 +96,35 @@ email_template: 'Custom Template' ) - post :update, params: { + patch :update, params: { course_id: course.id, reset_email_template: true, - tab: 'email' + page: 'emails' } - expect(response).to redirect_to(course_settings_path(course.id, tab: 'email')) + expect(response).to redirect_to(emails_course_settings_path(course.id)) expect(flash[:notice]).to eq('Email templates reset to defaults.') # We won't test the exact content since that requires knowledge of the constants end end + + describe 'GET #approvals' do + it 'renders the approvals page' do + get :approvals, params: { course_id: course.id } + + expect(response).to have_http_status(:ok) + expect(response).to render_template(:approvals) + end + end + + describe 'GET #emails' do + it 'renders the email templates page' do + get :emails, params: { course_id: course.id } + + expect(response).to have_http_status(:ok) + expect(response).to render_template(:emails) + end + end end describe 'pending requests count' do @@ -153,10 +168,10 @@ requests = instance_double(ActiveRecord::Relation, count: 1) allow(Request).to receive(:where).with(course_id: course.id, status: 'pending').and_return(requests) - post :update, params: { + patch :update, params: { course_id: course.id, - course_settings: { enable_extensions: 'true' }, - tab: 'general' + course_settings: { auto_approve_days: '3' }, + page: 'approvals' } expect(assigns(:pending_requests_count)).to eq(1) @@ -167,10 +182,10 @@ requests = instance_double(ActiveRecord::Relation, count: 0) allow(Request).to receive(:where).with(course_id: course.id, status: 'pending').and_return(requests) - post :update, params: { + patch :update, params: { course_id: course.id, - course_settings: { enable_extensions: 'true' }, - tab: 'general' + course_settings: { auto_approve_days: '3' }, + page: 'approvals' } expect(assigns(:pending_requests_count)).to eq(0) @@ -197,13 +212,13 @@ end it 'denies access to update course settings' do - post :update, params: { + patch :update, params: { course_id: course.id, course_settings: { enable_extensions: 'true', auto_approve_days: '99' }, - tab: 'general' + page: 'approvals' } expect(response).to redirect_to(courses_path) @@ -215,10 +230,10 @@ end it 'denies access to reset email templates' do - post :update, params: { + patch :update, params: { course_id: course.id, reset_email_template: true, - tab: 'email' + page: 'emails' } expect(response).to redirect_to(courses_path) @@ -230,10 +245,10 @@ it 'redirects to root path when user is not authenticated' do session[:user_id] = 'non_existent_id' - post :update, params: { + patch :update, params: { course_id: course.id, course_settings: { enable_extensions: 'true' }, - tab: 'general' + page: 'approvals' } expect(response).to redirect_to(root_path) @@ -244,10 +259,10 @@ session[:user_id] = instructor.canvas_uid Enrollment.create!(user: instructor, course: course, role: 'teacher') - post :update, params: { + patch :update, params: { course_id: 999, course_settings: { enable_extensions: 'true' }, - tab: 'general' + page: 'approvals' } expect(response).to redirect_to(courses_path) diff --git a/spec/controllers/courses_controller_spec.rb b/spec/controllers/courses_controller_spec.rb index 797a4586..1b1a96a3 100644 --- a/spec/controllers/courses_controller_spec.rb +++ b/spec/controllers/courses_controller_spec.rb @@ -133,6 +133,139 @@ expect(response).to redirect_to(courses_path) expect(flash[:alert]).to eq('You do not have access to this page.') end + + context 'as an instructor' do + let(:instructor) { User.create!(email: 'teacher@example.com', canvas_uid: '999', name: 'Teacher') } + + before do + session[:user_id] = instructor.canvas_uid + instructor.lms_credentials.create!( + lms_name: 'canvas', token: 't', refresh_token: 'r', expire_time: 1.hour.from_now + ) + UserToCourse.create!(user: instructor, course: course, role: 'teacher') + end + + it 'renders the Course Details page' do + get :edit, params: { id: course.id } + + expect(response).to render_template(:edit) + end + end + end + + describe 'PATCH #update' do + let(:instructor) { User.create!(email: 'teacher@example.com', canvas_uid: '999', name: 'Teacher') } + + before do + session[:user_id] = instructor.canvas_uid + instructor.lms_credentials.create!( + lms_name: 'canvas', token: 't', refresh_token: 'r', expire_time: 1.hour.from_now + ) + UserToCourse.create!(user: instructor, course: course, role: 'teacher') + end + + it 'updates the name, code and semester from the dropdowns' do + patch :update, params: { + id: course.id, + course: { course_name: 'New Name', course_code: 'NEW1', semester_season: 'Fall', semester_year: '2025' } + } + + expect(response).to redirect_to(edit_course_path(course)) + expect(flash[:notice]).to eq('Course details updated successfully.') + course.reload + expect(course.course_name).to eq('New Name') + expect(course.course_code).to eq('NEW1') + expect(course.semester).to eq('Fall 2025') + end + + it 'can flag the course as a demo course' do + patch :update, params: { id: course.id, course: { course_name: 'Test Course', demo_course: '1' } } + + expect(course.reload.demo_course).to be true + end + + it 'leaves the semester unchanged when the dropdowns are blank' do + course.update!(semester: 'weird-format') + + patch :update, params: { + id: course.id, + course: { course_name: 'Kept Name', semester_season: '', semester_year: '' } + } + + course.reload + expect(course.course_name).to eq('Kept Name') + expect(course.semester).to eq('weird-format') + end + + it 'redirects non-instructor users' do + session[:user_id] = user.canvas_uid + + patch :update, params: { id: course.id, course: { course_name: 'Nope' } } + + expect(response).to redirect_to(course_path(course)) + expect(flash[:alert]).to eq('You do not have access to this page.') + expect(course.reload.course_name).to eq('Test Course') + end + + it 're-renders with an alert when validation fails' do + patch :update, params: { id: course.id, course: { course_name: '' } } + + expect(response).to have_http_status(:unprocessable_content) + expect(flash[:alert]).to include('Failed to update course details') + end + + it 'saves the course-settings fields shown on Course Details' do + patch :update, params: { + id: course.id, + course: { course_name: 'Test Course' }, + course_settings: { + enable_extensions: 'true', + enable_gradescope: 'true', + gradescope_course_url: 'https://www.gradescope.com/courses/123456', + enable_emails: 'true', + reply_email: 'staff@example.com' + } + } + + expect(response).to redirect_to(edit_course_path(course)) + settings = course.reload.course_settings + expect(settings.enable_extensions).to be true + expect(settings.enable_gradescope).to be true + expect(settings.gradescope_course_url).to eq('https://www.gradescope.com/courses/123456') + expect(settings.enable_emails).to be true + expect(settings.reply_email).to eq('staff@example.com') + end + + it 'sends a Slack ping when the webhook is newly enabled' do + expect(SlackNotifier).to receive(:notify).and_return(true) + + patch :update, params: { + id: course.id, + course: { course_name: 'Test Course' }, + course_settings: { + enable_slack_webhook_url: 'true', + slack_webhook_url: 'https://hooks.slack.com/services/T/B/x' + } + } + + expect(response).to redirect_to(edit_course_path(course)) + expect(flash[:notice]).to match(/Check your Slack channel/) + end + + it 'warns when the Slack ping fails' do + allow(SlackNotifier).to receive(:notify).and_return(false) + + patch :update, params: { + id: course.id, + course: { course_name: 'Test Course' }, + course_settings: { + enable_slack_webhook_url: 'true', + slack_webhook_url: 'https://hooks.slack.com/services/T/B/x' + } + } + + expect(flash[:alert]).to include('Failed to send Slack notification') + end end describe 'POST #create' do diff --git a/spec/features/accessibility_spec.rb b/spec/features/accessibility_spec.rb index 3ce21aa1..7916fb81 100644 --- a/spec/features/accessibility_spec.rb +++ b/spec/features/accessibility_spec.rb @@ -271,7 +271,7 @@ def wait_for_page_to_load end it 'Edit form settings page should be accessible for teacher', :a11y do - test_page_accessibility("/courses/#{course1.id}/form_setting/edit", :teacher, theme, "edit_form_settings_#{theme}_teacher.png") + test_page_accessibility("/courses/#{course1.id}/settings/form/edit", :teacher, theme, "edit_form_settings_#{theme}_teacher.png") expect(page).to be_axe_clean end diff --git a/spec/models/course_spec.rb b/spec/models/course_spec.rb index ad0a0013..456467cb 100644 --- a/spec/models/course_spec.rb +++ b/spec/models/course_spec.rb @@ -364,6 +364,40 @@ def create_staff(email, canvas_uid, role, with_credentials: true) end end + describe '.semester_year_options' do + it 'spans 2012 through next year relative to the given date' do + options = described_class.semester_year_options(Date.new(2026, 6, 23)) + expect(options.first).to eq(2012) + expect(options.last).to eq(2027) + end + end + + describe '.parse_semester' do + around do |example| + travel_to(Date.new(2026, 6, 23)) { example.run } + end + + it 'splits a well-formed semester into [season, year]' do + expect(described_class.parse_semester('Spring 2026')).to eq([ 'Spring', 2026 ]) + expect(described_class.parse_semester('Fall 2025')).to eq([ 'Fall', 2025 ]) + end + + it 'returns [nil, nil] for an unrecognized season' do + expect(described_class.parse_semester('Autumn 2026')).to eq([ nil, nil ]) + end + + it 'returns [nil, nil] for an out-of-range year' do + expect(described_class.parse_semester('Spring 2011')).to eq([ nil, nil ]) + expect(described_class.parse_semester('Spring 2099')).to eq([ nil, nil ]) + end + + it 'returns [nil, nil] for blank or malformed input' do + expect(described_class.parse_semester(nil)).to eq([ nil, nil ]) + expect(described_class.parse_semester('')).to eq([ nil, nil ]) + expect(described_class.parse_semester('Spring2026')).to eq([ nil, nil ]) + end + end + describe '.semester_from_term' do it 'returns the term name when present' do expect(described_class.semester_from_term({ 'name' => 'Spring 2026' })).to eq('Spring 2026') diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 2749c91b..d76cbbfe 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -64,6 +64,9 @@ Rails.root.join('spec/fixtures') ] + # Enables travel_to/freeze_time for deterministic, time-dependent specs. + config.include ActiveSupport::Testing::TimeHelpers + config.before(:suite) do Rails.application.load_seed end