From 37b1a1fe4c4824a5a5fc27c2b2b161256e69e57a Mon Sep 17 00:00:00 2001 From: Noah Nizamian Date: Wed, 4 Mar 2026 16:51:27 -0800 Subject: [PATCH 01/32] LMS credentials now uses LMS ID instead of LMS name --- app/controllers/session_controller.rb | 2 +- app/models/lms.rb | 25 +++++++++++------- app/models/lms_credential.rb | 7 +++-- app/models/user.rb | 3 +-- db/api_spec_seeds.rb | 2 +- ..._refactor_lms_credentials_to_use_lms_id.rb | 26 +++++++++++++++++++ ...0001_validate_lms_credentials_lms_id_fk.rb | 5 ++++ db/schema.rb | 5 ++-- .../application_controller_spec.rb | 3 ++- .../concerns/token_refreshable_spec.rb | 3 ++- .../course_settings_controller_spec.rb | 5 ++-- spec/controllers/courses_controller_spec.rb | 9 ++++--- spec/controllers/requests_controller_spec.rb | 6 ++--- spec/factories/lms.rb | 13 +++++++++- spec/factories/lms_credential.rb | 9 ++++++- spec/features/accessibility_spec.rb | 4 +-- spec/models/course_spec.rb | 3 ++- spec/models/lms_credential_spec.rb | 6 +++-- spec/models/request_spec.rb | 3 ++- spec/models/user_spec.rb | 15 +++++++---- 20 files changed, 113 insertions(+), 41 deletions(-) create mode 100644 db/migrate/20260304000000_refactor_lms_credentials_to_use_lms_id.rb create mode 100644 db/migrate/20260304000001_validate_lms_credentials_lms_id_fk.rb diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index 75815b0f..b87348ab 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -138,7 +138,7 @@ def update_user_credential(user, token) ) else user.lms_credentials.create!( - lms_name: 'canvas', + lms_id: Lms.CANVAS_LMS.id, token: token.token, refresh_token: token.refresh_token, expire_time: Time.zone.at(token.expires_at) diff --git a/app/models/lms.rb b/app/models/lms.rb index 08aeee1e..ce2662b2 100644 --- a/app/models/lms.rb +++ b/app/models/lms.rb @@ -17,23 +17,28 @@ class Lms < ApplicationRecord # Relationship with Assignment has_many :assignments + # Relationship with LmsCredential + has_many :lms_credentials, dependent: :destroy + # Singleton instances for each LMS def self.CANVAS_LMS - @canvas_lms ||= find_or_create_by( + @canvas_lms ||= find_by(id: 1) || find_or_create_by!( id: 1, - lms_name: 'Canvas', - lms_base_url: ENV.fetch('CANVAS_URL', ''), - use_auth_token: true - ) + lms_name: 'Canvas' + ) do |lms| + lms.lms_base_url = ENV.fetch('CANVAS_URL', '') + lms.use_auth_token = true + end end def self.GRADESCOPE_LMS - @gradescope_lms ||= find_or_create_by( + @gradescope_lms ||= find_by(id: 2) || find_or_create_by!( id: 2, - lms_name: 'Gradescope', - lms_base_url: 'https://www.gradescope.com', - use_auth_token: false - ) + lms_name: 'Gradescope' + ) do |lms| + lms.lms_base_url = 'https://www.gradescope.com' + lms.use_auth_token = false + end end # Map a linked LMS to the appropriate API facade which can be used to post extension requests diff --git a/app/models/lms_credential.rb b/app/models/lms_credential.rb index aa895068..7480a5e5 100644 --- a/app/models/lms_credential.rb +++ b/app/models/lms_credential.rb @@ -5,7 +5,6 @@ # # id :bigint not null, primary key # expire_time :datetime -# lms_name :string # password :string # refresh_token :string # token :string @@ -13,6 +12,7 @@ # created_at :datetime not null # updated_at :datetime not null # external_user_id :string +# lms_id :bigint # user_id :bigint # # Indexes @@ -21,15 +21,18 @@ # # Foreign Keys # +# fk_rails_... (lms_id => lmss.id) # fk_rails_... (user_id => users.id) # class LmsCredential < ApplicationRecord # Belongs to a User belongs_to :user + # Belongs to an LMS + belongs_to :lms + # Encryption for tokens encrypts :token, :refresh_token # LMS must exist - validates :lms_name, presence: true end diff --git a/app/models/user.rb b/app/models/user.rb index 9cac5f45..6695a9a7 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -38,9 +38,8 @@ class User < ApplicationRecord has_many :user_to_courses has_many :courses, through: :user_to_courses - # TODO: We should probably use lms_id over lms_name def canvas_credentials - lms_credentials.find_by(lms_name: 'canvas') + lms_credentials.find_by(lms_id: Lms.CANVAS_LMS.id) end def token_expired? diff --git a/db/api_spec_seeds.rb b/db/api_spec_seeds.rb index 3e99bc2b..50748302 100644 --- a/db/api_spec_seeds.rb +++ b/db/api_spec_seeds.rb @@ -50,7 +50,7 @@ test_lms_credential = LmsCredential.create!({ user_id: test_user.id, - lms_name: "canvas", + lms_id: canvas.id, token: "test token", external_user_id: "44444", }) diff --git a/db/migrate/20260304000000_refactor_lms_credentials_to_use_lms_id.rb b/db/migrate/20260304000000_refactor_lms_credentials_to_use_lms_id.rb new file mode 100644 index 00000000..d267a21a --- /dev/null +++ b/db/migrate/20260304000000_refactor_lms_credentials_to_use_lms_id.rb @@ -0,0 +1,26 @@ +class RefactorLmsCredentialsToUseLmsId < ActiveRecord::Migration[7.1] + def change + # Add lms_id column as foreign key without validation + add_column :lms_credentials, :lms_id, :bigint + add_foreign_key :lms_credentials, :lmss, column: :lms_id, validate: false + + # Migrate existing data: populate lms_id based on lms_name + reversible do |dir| + dir.up do + safety_assured do + execute <<-SQL + UPDATE lms_credentials + SET lms_id = lmss.id + FROM lmss + WHERE LOWER(lms_credentials.lms_name) = LOWER(lmss.lms_name) + SQL + end + end + end + + # Remove lms_name column (after data migration) + safety_assured do + remove_column :lms_credentials, :lms_name, :string + end + end +end diff --git a/db/migrate/20260304000001_validate_lms_credentials_lms_id_fk.rb b/db/migrate/20260304000001_validate_lms_credentials_lms_id_fk.rb new file mode 100644 index 00000000..a80bca51 --- /dev/null +++ b/db/migrate/20260304000001_validate_lms_credentials_lms_id_fk.rb @@ -0,0 +1,5 @@ +class ValidateLmsCredentialsLmsIdFk < ActiveRecord::Migration[7.1] + def change + validate_foreign_key :lms_credentials, :lmss + end +end diff --git a/db/schema.rb b/db/schema.rb index 52c429cc..b9dea08c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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_03_04_000001) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -159,7 +159,6 @@ create_table "lms_credentials", force: :cascade do |t| t.bigint "user_id" - t.string "lms_name" t.string "username" t.string "password" t.string "token" @@ -168,6 +167,7 @@ t.datetime "updated_at", null: false t.string "external_user_id" t.datetime "expire_time" + t.bigint "lms_id" t.index ["user_id"], name: "index_lms_credentials_on_user_id" end @@ -232,6 +232,7 @@ add_foreign_key "extensions", "assignments" add_foreign_key "extensions", "users", column: "last_processed_by_id" add_foreign_key "form_settings", "courses" + add_foreign_key "lms_credentials", "lmss" add_foreign_key "lms_credentials", "users" add_foreign_key "requests", "assignments" add_foreign_key "requests", "courses" diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index cb795dab..2a44ea6b 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -14,8 +14,9 @@ def test_auth let(:user) do User.create!(email: 'test@example.com', canvas_uid: '123').tap do |u| + Lms.find_or_create_by(id: 1) { |l| l.lms_name = 'Canvas'; l.use_auth_token = true } u.lms_credentials.create!( - lms_name: 'canvas', + lms_id: 1, token: 'valid_token', refresh_token: 'refresh_token', expire_time: 1.hour.from_now diff --git a/spec/controllers/concerns/token_refreshable_spec.rb b/spec/controllers/concerns/token_refreshable_spec.rb index 599d8e23..4a7f308a 100644 --- a/spec/controllers/concerns/token_refreshable_spec.rb +++ b/spec/controllers/concerns/token_refreshable_spec.rb @@ -21,8 +21,9 @@ def current_user let(:user) do User.create!(email: 'test@example.com', canvas_uid: '123').tap do |u| + Lms.find_or_create_by(id: 1) { |l| l.lms_name = 'Canvas'; l.use_auth_token = true } u.lms_credentials.create!( - lms_name: 'canvas', + lms_id: 1, token: 'valid_token', refresh_token: 'refresh_token', expire_time: 10.minutes.from_now diff --git a/spec/controllers/course_settings_controller_spec.rb b/spec/controllers/course_settings_controller_spec.rb index c6ac03a8..7d273e79 100644 --- a/spec/controllers/course_settings_controller_spec.rb +++ b/spec/controllers/course_settings_controller_spec.rb @@ -6,8 +6,9 @@ let(:course) { Course.create!(course_name: 'Test Course', canvas_id: '123') } before do + Lms.find_or_create_by(id: 1) { |l| l.lms_name = 'Canvas'; l.use_auth_token = true } instructor.lms_credentials.create!( - lms_name: 'canvas', + lms_id: 1, token: 'fake_token', refresh_token: 'fake_refresh_token', expire_time: 1.hour.from_now @@ -190,7 +191,7 @@ before do session[:user_id] = student.canvas_uid student.lms_credentials.create!( - lms_name: 'canvas', + lms_id: 1, token: 'student_token', refresh_token: 'student_refresh_token', expire_time: 1.hour.from_now diff --git a/spec/controllers/courses_controller_spec.rb b/spec/controllers/courses_controller_spec.rb index 39629b9d..08c2b8ee 100644 --- a/spec/controllers/courses_controller_spec.rb +++ b/spec/controllers/courses_controller_spec.rb @@ -8,10 +8,11 @@ let(:course_settings) { CourseSettings.create!(course: course, enable_extensions: true) } before do + Lms.find_or_create_by(id: 1) { |l| l.lms_name = 'Canvas'; l.use_auth_token = true } session[:user_id] = user.canvas_uid UserToCourse.create!(user: user, course: course, role: 'student') user.lms_credentials.create!( - lms_name: 'canvas', + lms_id: 1, token: 'fake_token', refresh_token: 'fake_refresh_token', expire_time: 1.hour.from_now @@ -118,7 +119,8 @@ describe 'GET #new' do before do # Create a fake LMS credential with a token - user.lms_credentials.create!(lms_name: 'canvas', token: 'fake_token', expire_time: 1.hour.from_now) + Lms.find_or_create_by(id: 1) { |l| l.lms_name = 'Canvas'; l.use_auth_token = true } + user.lms_credentials.create!(lms_id: 1, token: 'fake_token', expire_time: 1.hour.from_now) allow(Course).to receive(:fetch_courses).and_return([ { @@ -167,7 +169,8 @@ describe 'GET #enrollments' do before do # Create LMS credentials so user has a token - user.lms_credentials.create!(lms_name: 'canvas', token: 'fake_token', expire_time: 1.hour.from_now) + Lms.find_or_create_by(id: 1) { |l| l.lms_name = 'Canvas'; l.use_auth_token = true } + user.lms_credentials.create!(lms_id: 1, token: 'fake_token', expire_time: 1.hour.from_now) # Add user as a teacher so they are allowed to view enrollments UserToCourse.create!(user: user, course: course, role: 'teacher') diff --git a/spec/controllers/requests_controller_spec.rb b/spec/controllers/requests_controller_spec.rb index 810033d2..d8efd148 100644 --- a/spec/controllers/requests_controller_spec.rb +++ b/spec/controllers/requests_controller_spec.rb @@ -29,7 +29,7 @@ CourseToLms.create!(course:, lms_id: 1) user.lms_credentials.create!( - lms_name: 'canvas', + lms_id: 1, token: 'fake_token', refresh_token: 'fake_refresh_token', expire_time: 1.hour.from_now @@ -294,7 +294,7 @@ session[:user_id] = instructor.canvas_uid UserToCourse.create!(user: instructor, course: course, role: 'teacher') instructor.lms_credentials.create!( - lms_name: 'canvas', + lms_id: 1, token: 'instructor_token', refresh_token: 'instructor_refresh', expire_time: 1.hour.from_now @@ -494,7 +494,7 @@ # Create credentials for user user.lms_credentials.create!( - lms_name: 'canvas', + lms_id: 1, token: 'fake_token', refresh_token: 'fake_refresh_token', expire_time: 1.hour.from_now diff --git a/spec/factories/lms.rb b/spec/factories/lms.rb index c5c6a006..95bd90e3 100644 --- a/spec/factories/lms.rb +++ b/spec/factories/lms.rb @@ -1,7 +1,18 @@ FactoryBot.define do factory :lms do - id { 1 } # Explicitly set id to 1 (must be hardcoded) + sequence(:id) { |n| n } lms_name { 'Canvas' } use_auth_token { true } + + trait :canvas do + id { 1 } + lms_name { 'Canvas' } + end + + trait :gradescope do + id { 2 } + lms_name { 'Gradescope' } + use_auth_token { false } + end end end diff --git a/spec/factories/lms_credential.rb b/spec/factories/lms_credential.rb index 7dff4782..4e3b097d 100644 --- a/spec/factories/lms_credential.rb +++ b/spec/factories/lms_credential.rb @@ -1,9 +1,16 @@ FactoryBot.define do factory :lms_credential do association :user - lms_name { 'canvas' } + lms_id { 1 } token { 'fake_token' } refresh_token { 'fake_refresh_token' } expire_time { 1.hour.from_now } + + before(:create) do |credential| + # Ensure the LMS with id: 1 exists + unless Lms.exists?(id: 1) + Lms.create!(id: 1, lms_name: 'Canvas', use_auth_token: true) + end + end end end diff --git a/spec/features/accessibility_spec.rb b/spec/features/accessibility_spec.rb index 824f448b..8ef91245 100644 --- a/spec/features/accessibility_spec.rb +++ b/spec/features/accessibility_spec.rb @@ -165,8 +165,8 @@ def wait_for_page_to_load # Set a default wait time Capybara.default_max_wait_time = 3 - create(:lms_credential, user: teacher1, lms_name: 'canvas') - create(:lms_credential, user: student1, lms_name: 'canvas') + create(:lms_credential, user: teacher1, lms_id: 1) + create(:lms_credential, user: student1, lms_id: 1) stub_request(:get, %r{#{ENV.fetch('CANVAS_URL')}/api/v1/courses/.*}) .to_return(status: 200, body: [].to_json) diff --git a/spec/models/course_spec.rb b/spec/models/course_spec.rb index 7d30c67d..accc1140 100644 --- a/spec/models/course_spec.rb +++ b/spec/models/course_spec.rb @@ -40,8 +40,9 @@ it 'returns the correct user for auto approval' do course = described_class.create!(canvas_id: 'canvas_123', course_name: 'Test', course_code: 'TEST101') user = User.create!(email: 'test@example.com', canvas_uid: '123') + Lms.find_or_create_by(id: 1) { |l| l.lms_name = 'Canvas'; l.use_auth_token = true } user.lms_credentials.create!( - lms_name: 'canvas', + lms_id: 1, token: 'valid_token', refresh_token: 'refresh_token', expire_time: 1.hour.from_now diff --git a/spec/models/lms_credential_spec.rb b/spec/models/lms_credential_spec.rb index 3a845caf..121115dc 100644 --- a/spec/models/lms_credential_spec.rb +++ b/spec/models/lms_credential_spec.rb @@ -5,7 +5,6 @@ # # id :bigint not null, primary key # expire_time :datetime -# lms_name :string # password :string # refresh_token :string # token :string @@ -13,6 +12,7 @@ # created_at :datetime not null # updated_at :datetime not null # external_user_id :string +# lms_id :bigint # user_id :bigint # # Indexes @@ -21,6 +21,7 @@ # # Foreign Keys # +# fk_rails_... (lms_id => lmss.id) # fk_rails_... (user_id => users.id) # require 'rails_helper' @@ -40,10 +41,11 @@ def self.mock_get_service(token, refresh_token) RSpec.describe LmsCredential, type: :model do describe 'Token Encryption' do let(:user) { User.create!(email: 'test@example.com') } + let!(:lms) { Lms.find_or_create_by(id: 1) { |lms| lms.lms_name = 'Canvas'; lms.use_auth_token = true } } let!(:credential) do described_class.create!( user: user, - lms_name: 'ExampleLMS', + lms_id: lms.id, username: 'testuser', password: 'testpassword', token: 'sensitive_token', diff --git a/spec/models/request_spec.rb b/spec/models/request_spec.rb index 9e4156f3..51fe8a35 100644 --- a/spec/models/request_spec.rb +++ b/spec/models/request_spec.rb @@ -70,8 +70,9 @@ before do UserToCourse.create!(user: user, course: course, role: 'student') + create(:lms) user.lms_credentials.create!( - lms_name: 'canvas', + lms_id: 1, token: 'fake_token', refresh_token: 'fake_refresh_token', expire_time: 1.hour.from_now diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index a929e1d7..feabdbc3 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -31,8 +31,9 @@ context 'when the token is still valid' do before do + Lms.find_or_create_by(id: 1) { |lms| lms.lms_name = 'Canvas'; lms.use_auth_token = true } user.lms_credentials.create!( - lms_name: 'canvas', + lms_id: 1, token: 'valid_token', refresh_token: 'refresh_token', expire_time: 1.hour.from_now @@ -46,8 +47,9 @@ context 'when the token is expired' do before do + Lms.find_or_create_by(id: 1) { |lms| lms.lms_name = 'Canvas'; lms.use_auth_token = true } user.lms_credentials.create!( - lms_name: 'canvas', + lms_id: 1, token: 'expired_token', refresh_token: 'refresh_token', expire_time: 1.hour.ago @@ -64,8 +66,9 @@ let(:user) { described_class.create!(email: 'test@example.com', canvas_uid: '123') } it 'returns the correct credentials for a user' do + Lms.find_or_create_by(id: 1) { |lms| lms.lms_name = 'Canvas'; lms.use_auth_token = true } user.lms_credentials.create!( - lms_name: 'canvas', + lms_id: 1, token: 'valid_token', refresh_token: 'refresh_token', expire_time: 1.hour.from_now @@ -81,8 +84,9 @@ context 'when token does not expire soon' do before do + Lms.find_or_create_by(id: 1) { |lms| lms.lms_name = 'Canvas'; lms.use_auth_token = true } user.lms_credentials.create!( - lms_name: 'canvas', + lms_id: 1, token: 'valid_token', refresh_token: 'refresh_token', expire_time: 1.hour.from_now @@ -96,8 +100,9 @@ context 'when token expires soon and is refreshed' do let(:credential) do + Lms.find_or_create_by(id: 1) { |lms| lms.lms_name = 'Canvas'; lms.use_auth_token = true } user.lms_credentials.create!( - lms_name: 'canvas', + lms_id: 1, token: 'stale_token', refresh_token: 'refresh_token', expire_time: 5.minutes.from_now From d2bbb10ac851ab832dfadf409693b59ba97edc44 Mon Sep 17 00:00:00 2001 From: Noah Nizamian Date: Thu, 5 Mar 2026 15:52:26 -0800 Subject: [PATCH 02/32] Implement UserToCoursesController#toggle_allow_extended_requests - Implemented user_to_courses_controller.rb with role-based authorization - PATCH endpoint to toggle allow_extended_requests on enrollments - Authorization: only teachers can toggle - Uses lms_id FK pattern from LMS credentials refactoring - Added teacher? method to UserToCourse model for role checking - Complete spec with 7 test scenarios (instructor, student, missing resources) - All tests passing: 365 examples, 0 failures, 80.88% coverage --- app/controllers/user_to_courses_controller.rb | 50 +++++++++++++++---- app/models/user_to_course.rb | 4 ++ .../user_to_courses_controller_spec.rb | 12 +++-- 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/app/controllers/user_to_courses_controller.rb b/app/controllers/user_to_courses_controller.rb index 70451135..d06d414b 100644 --- a/app/controllers/user_to_courses_controller.rb +++ b/app/controllers/user_to_courses_controller.rb @@ -1,21 +1,49 @@ class UserToCoursesController < ApplicationController - before_action :authenticate_user + before_action :authenticate_user! before_action :set_course + before_action :set_enrollment + before_action :authorize_instructor! def toggle_allow_extended_requests - @enrollment = @course.user_to_courses.find(params[:id]) - - unless @role == 'instructor' - Rails.logger.error "Role #{@role} does not have permission to toggle allow_extended_requests" - flash.now[:alert] = 'You do not have permission to perform this action.' - return render json: { redirect_to: course_path(@course) }, status: :forbidden - end - if @enrollment.update(allow_extended_requests: params[:allow_extended_requests]) render json: { success: true }, status: :ok else - flash[:alert] = "Failed to update enrollment: #{@enrollment.errors.full_messages.to_sentence}" - render json: { redirect_to: course_path(@course) }, status: :unprocessable_entity + render json: { + success: false, + errors: @enrollment.errors.full_messages, + redirect_to: courses_path + }, status: :unprocessable_entity + end + end + + private + + def authenticate_user! + user_id = session[:user_id] + @current_user = User.find_by(canvas_uid: user_id) if user_id + redirect_to root_path unless @current_user + end + + def set_course + @course = Course.find_by(id: params[:course_id]) + unless @course + flash[:alert] = 'Course not found.' + redirect_to courses_path + end + end + + def set_enrollment + @enrollment = UserToCourse.find(params[:id]) + end + + def authorize_instructor! + user_to_course = UserToCourse.find_by(user: @current_user, course: @course) + unless user_to_course&.teacher? + render json: { + success: false, + error: 'Forbidden', + redirect_to: courses_path + }, status: :forbidden end end end diff --git a/app/models/user_to_course.rb b/app/models/user_to_course.rb index abb63fa2..822fb8af 100644 --- a/app/models/user_to_course.rb +++ b/app/models/user_to_course.rb @@ -46,6 +46,10 @@ def student? role == 'student' end + def teacher? + role == 'teacher' + end + def self.roles [ 'student' ] + UserToCourse.staff_roles end diff --git a/spec/controllers/user_to_courses_controller_spec.rb b/spec/controllers/user_to_courses_controller_spec.rb index eaaa61e7..90c53c2f 100644 --- a/spec/controllers/user_to_courses_controller_spec.rb +++ b/spec/controllers/user_to_courses_controller_spec.rb @@ -9,11 +9,12 @@ describe 'PATCH #toggle_allow_extended_requests' do context 'when user is an instructor' do before do + Lms.find_or_create_by(id: 1) { |l| l.lms_name = 'Canvas'; l.use_auth_token = true } UserToCourse.create!(user: instructor, course: course, role: 'teacher') student_enrollment session[:user_id] = instructor.canvas_uid instructor.lms_credentials.create!( - lms_name: 'canvas', + lms_id: 1, token: 'fake_token', refresh_token: 'fake_refresh_token', expire_time: 1.hour.from_now @@ -65,10 +66,11 @@ context 'when user is a student' do before do + Lms.find_or_create_by(id: 1) { |l| l.lms_name = 'Canvas'; l.use_auth_token = true } student_enrollment session[:user_id] = student_user.canvas_uid student_user.lms_credentials.create!( - lms_name: 'canvas', + lms_id: 1, token: 'fake_token', refresh_token: 'fake_refresh_token', expire_time: 1.hour.from_now @@ -99,10 +101,11 @@ context 'when course does not exist' do before do + Lms.find_or_create_by(id: 1) { |l| l.lms_name = 'Canvas'; l.use_auth_token = true } student_enrollment session[:user_id] = instructor.canvas_uid instructor.lms_credentials.create!( - lms_name: 'canvas', + lms_id: 1, token: 'fake_token', refresh_token: 'fake_refresh_token', expire_time: 1.hour.from_now @@ -123,10 +126,11 @@ context 'when enrollment does not exist' do before do + Lms.find_or_create_by(id: 1) { |l| l.lms_name = 'Canvas'; l.use_auth_token = true } UserToCourse.create!(user: instructor, course: course, role: 'teacher') session[:user_id] = instructor.canvas_uid instructor.lms_credentials.create!( - lms_name: 'canvas', + lms_id: 1, token: 'fake_token', refresh_token: 'fake_refresh_token', expire_time: 1.hour.from_now From 6f04fe7b5fea4d5a571ce16d96c6edfd0afca396 Mon Sep 17 00:00:00 2001 From: Noah Nizamian Date: Sun, 29 Mar 2026 14:19:48 -0700 Subject: [PATCH 03/32] patch + rspec tests --- app/jobs/sync_all_course_assignments_job.rb | 16 ++- lib/lmss/base_assignment.rb | 1 + lib/lmss/canvas/assignment.rb | 7 +- .../sync_all_course_assignments_job_spec.rb | 105 ++++++++++++++++-- 4 files changed, 116 insertions(+), 13 deletions(-) diff --git a/app/jobs/sync_all_course_assignments_job.rb b/app/jobs/sync_all_course_assignments_job.rb index d39f9aff..cec3ebd1 100644 --- a/app/jobs/sync_all_course_assignments_job.rb +++ b/app/jobs/sync_all_course_assignments_job.rb @@ -47,8 +47,10 @@ def sync_assignment(course_to_lms, lms_assignment, results) # Use shared LmsAssignment to populate Assignment assignment.name = lms_assignment.name - assignment.due_date = lms_assignment.due_date - assignment.late_due_date = lms_assignment.late_due_date + unless preserve_existing_dates?(assignment, lms_assignment) + assignment.due_date = lms_assignment.due_date + assignment.late_due_date = lms_assignment.late_due_date + end assignment.external_assignment_id = lms_assignment.id if assignment.new_record? @@ -60,4 +62,14 @@ def sync_assignment(course_to_lms, lms_assignment, results) end assignment.save! end + + private + + def preserve_existing_dates?(assignment, lms_assignment) + return false if assignment.new_record? + return false unless lms_assignment.is_a?(Lmss::Canvas::Assignment) + return false if lms_assignment.base_date_present? + + assignment.due_date.present? || assignment.late_due_date.present? + end end diff --git a/lib/lmss/base_assignment.rb b/lib/lmss/base_assignment.rb index 26c02376..1ecc38da 100644 --- a/lib/lmss/base_assignment.rb +++ b/lib/lmss/base_assignment.rb @@ -4,5 +4,6 @@ def id = raise(NotImplementedError) def name = raise(NotImplementedError) def due_date = raise(NotImplementedError) def late_due_date = raise(NotImplementedError) + def base_date_present? = false end end diff --git a/lib/lmss/canvas/assignment.rb b/lib/lmss/canvas/assignment.rb index 4b273238..07d284a1 100644 --- a/lib/lmss/canvas/assignment.rb +++ b/lib/lmss/canvas/assignment.rb @@ -1,15 +1,20 @@ module Lmss module Canvas class Assignment < BaseAssignment - attr_reader :id, :name, :due_date, :late_due_date + attr_reader :id, :name, :due_date, :late_due_date, :base_date def initialize(data) @id = data['id'] @name = data['name'] + @base_date = data['base_date'] @due_date = extract_date_field(data, 'due_at') @late_due_date = extract_date_field(data, 'lock_at') end + def base_date_present? + @base_date.is_a?(Hash) && @base_date.any? + end + private def extract_date_field(assignment_data, field_name) diff --git a/spec/jobs/sync_all_course_assignments_job_spec.rb b/spec/jobs/sync_all_course_assignments_job_spec.rb index 079b0dd1..976e2c83 100644 --- a/spec/jobs/sync_all_course_assignments_job_spec.rb +++ b/spec/jobs/sync_all_course_assignments_job_spec.rb @@ -114,6 +114,43 @@ end end + context 'when Canvas omits base_date metadata' do + let(:canvas_assignments) do + [ + Lmss::Canvas::Assignment.new( + 'id' => '123', + 'name' => 'Assignment 1', + 'due_at' => '2025-01-30T23:59:00Z', + 'lock_at' => '2025-02-05T23:59:00Z', + 'base_date' => nil + ) + ] + end + + it 'preserves existing due dates for Canvas assignments' do + existing_assignment = create(:assignment, + course_to_lms: course_to_lms, + external_assignment_id: '123', + due_date: DateTime.parse('2025-01-15T23:59:00Z'), + late_due_date: DateTime.parse('2025-01-20T23:59:00Z') + ) + + described_class.perform_now(course_to_lms.id, sync_user.id) + + existing_assignment.reload + expect(existing_assignment.due_date).to eq(DateTime.parse('2025-01-15T23:59:00Z')) + expect(existing_assignment.late_due_date).to eq(DateTime.parse('2025-01-20T23:59:00Z')) + end + + it 'still sets dates for newly imported assignments' do + described_class.perform_now(course_to_lms.id, sync_user.id) + + assignment = Assignment.find_by(external_assignment_id: '123') + expect(assignment.due_date).to eq(DateTime.parse('2025-01-30T23:59:00Z')) + expect(assignment.late_due_date).to eq(DateTime.parse('2025-02-05T23:59:00Z')) + end + end + context 'when sync_user is not staff' do let(:student_user) { course.students.first } @@ -130,20 +167,68 @@ end end + describe '#sync_assignment' do + let(:job) { described_class.new } + let(:results) do + { + added_assignments: 0, + updated_assignments: 0, + unchanged_assignments: 0, + deleted_assignments: 0 + } + end + + it 'creates a new assignment and updates results' do + target_course_to_lms = course_to_lms + + lms_assignment = build_canvas_assignment( + 'id' => 'a123', + 'name' => 'HW1', + 'due_at' => '2025-01-15T23:59:00Z', + 'lock_at' => '2025-01-20T23:59:00Z' + ) - # THIS MUST BE REWRITTEN - # This was moved from Course.sync_assignment - # It is now a helper method within the job. - describe '.sync_assignment' do - it 'creates or updates an assignment' do - pending 'moved from course_spec and should be rewritten' - assignment_data = { 'id' => 'a123', 'name' => 'HW1', 'due_at' => 1.day.from_now.to_s } expect do - described_class.sync_assignment(course_to_lms, assignment_data) - end.to change(Assignment, :count).by(1) + job.send(:sync_assignment, target_course_to_lms, lms_assignment, results) + end.to change { Assignment.where(course_to_lms_id: target_course_to_lms.id).count }.by(1) - assignment = Assignment.last + assignment = Assignment.find_by(course_to_lms_id: target_course_to_lms.id, external_assignment_id: 'a123') expect(assignment.name).to eq('HW1') + expect(assignment.due_date).to eq(DateTime.parse('2025-01-15T23:59:00Z')) + expect(assignment.late_due_date).to eq(DateTime.parse('2025-01-20T23:59:00Z')) + expect(results[:added_assignments]).to eq(1) + expect(results[:updated_assignments]).to eq(0) + expect(results[:unchanged_assignments]).to eq(0) + end + + it 'updates an existing assignment and updates results' do + target_course_to_lms = course_to_lms + + existing_assignment = create(:assignment, + course_to_lms: target_course_to_lms, + external_assignment_id: 'a123', + name: 'Old HW Name', + due_date: DateTime.parse('2025-01-10T23:59:00Z') + ) + + lms_assignment = build_canvas_assignment( + 'id' => 'a123', + 'name' => 'HW1 Updated', + 'due_at' => '2025-01-25T23:59:00Z', + 'lock_at' => nil + ) + + expect do + job.send(:sync_assignment, target_course_to_lms, lms_assignment, results) + end.not_to change { Assignment.where(course_to_lms_id: target_course_to_lms.id).count } + + existing_assignment.reload + expect(existing_assignment.name).to eq('HW1 Updated') + expect(existing_assignment.due_date).to eq(DateTime.parse('2025-01-25T23:59:00Z')) + expect(existing_assignment.late_due_date).to be_nil + expect(results[:added_assignments]).to eq(0) + expect(results[:updated_assignments]).to eq(1) + expect(results[:unchanged_assignments]).to eq(0) end end From acfe1552aeab32b225fda7bfe620134224ba0ff2 Mon Sep 17 00:00:00 2001 From: Sarj-Basim Al Harbi <123058662+ba88im@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:23:22 -0700 Subject: [PATCH 04/32] Hide checkbox and status columns on mobile requests page Adjust DataTables data-priority values so only Actions, Assignment, and Name columns remain visible on small screens. Bulk action buttons are also hidden on mobile via Bootstrap responsive utility classes. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/views/requests/instructor_index.html.erb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/views/requests/instructor_index.html.erb b/app/views/requests/instructor_index.html.erb index 4274f8ef..4f6a8d54 100644 --- a/app/views/requests/instructor_index.html.erb +++ b/app/views/requests/instructor_index.html.erb @@ -31,7 +31,7 @@ id="requests-table"> - + Actions - Name - Assignment + Name + Assignment Student ID Requested At Original Due Date - Requested Due Date + Requested Due Date # of Days - Status + Status @@ -143,7 +143,7 @@ -
+
+
+ +
+ <%= 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' } %> +
+
+ +
+ +
+ <%= 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? %> +
Weekly notifications are sent on Fridays at 5:00 PM PT.
+
+
+
<% if @course.course_settings&.enable_extensions %> From 8a8984a5a9aaf3fa10109b6d1e11c0befa68e600 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 26 Apr 2026 14:05:03 -0700 Subject: [PATCH 13/32] Fix RuboCop offenses on email-notifications branch --- app/models/course_settings.rb | 2 +- lib/tasks/notifications.rake | 2 +- spec/jobs/pending_requests_notification_job_spec.rb | 2 +- spec/models/course_settings_spec.rb | 6 +++--- spec/tasks/notifications_rake_spec.rb | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/models/course_settings.rb b/app/models/course_settings.rb index a999a20c..0ef7cf6c 100644 --- a/app/models/course_settings.rb +++ b/app/models/course_settings.rb @@ -65,7 +65,7 @@ class CourseSettings < ApplicationRecord scope :with_pending_notifications, ->(frequency) { where(pending_notification_frequency: frequency) - .where.not(pending_notification_email: [nil, '']) + .where.not(pending_notification_email: [ nil, '' ]) } def automatic_approval_enabled? diff --git a/lib/tasks/notifications.rake b/lib/tasks/notifications.rake index 35da861f..978c5291 100644 --- a/lib/tasks/notifications.rake +++ b/lib/tasks/notifications.rake @@ -1,6 +1,6 @@ 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| + 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) diff --git a/spec/jobs/pending_requests_notification_job_spec.rb b/spec/jobs/pending_requests_notification_job_spec.rb index 3b368b61..54aa990c 100644 --- a/spec/jobs/pending_requests_notification_job_spec.rb +++ b/spec/jobs/pending_requests_notification_job_spec.rb @@ -32,7 +32,7 @@ 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.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") diff --git a/spec/models/course_settings_spec.rb b/spec/models/course_settings_spec.rb index 94848fa6..711a894a 100644 --- a/spec/models/course_settings_spec.rb +++ b/spec/models/course_settings_spec.rb @@ -266,15 +266,15 @@ other_course = create(:course, canvas_id: 'other_123', course_name: 'Other', course_code: 'OTHER101') other_course.course_settings.update!(pending_notification_frequency: 'weekly', pending_notification_email: 'b@example.com') - results = CourseSettings.with_pending_notifications('daily') + results = described_class.with_pending_notifications('daily') expect(results).to include(course_settings) expect(results).not_to include(other_course.course_settings) end it 'excludes records with nil email' do - course_settings.update_columns(pending_notification_frequency: 'daily', pending_notification_email: nil) + course_settings.update_columns(pending_notification_frequency: 'daily', pending_notification_email: nil) # rubocop:disable Rails/SkipsModelValidations - results = CourseSettings.with_pending_notifications('daily') + results = described_class.with_pending_notifications('daily') expect(results).not_to include(course_settings) end end diff --git a/spec/tasks/notifications_rake_spec.rb b/spec/tasks/notifications_rake_spec.rb index 4579d8ae..638d1c4e 100644 --- a/spec/tasks/notifications_rake_spec.rb +++ b/spec/tasks/notifications_rake_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' require 'rake' -RSpec.describe 'notifications:send_pending_digests' do +RSpec.describe 'notifications:send_pending_digests' do # rubocop:disable RSpec/DescribeClass before(:all) do Rails.application.load_tasks end From 72e667761ad1c283b3b0afd87857b6fe5d6e3ea7 Mon Sep 17 00:00:00 2001 From: Anthony Dam Date: Wed, 29 Apr 2026 21:29:03 -0700 Subject: [PATCH 14/32] re-run CI From 7f86619de41ec34230a08a3ce23063884dd52449 Mon Sep 17 00:00:00 2001 From: Anthony Dam Date: Wed, 29 Apr 2026 21:29:23 -0700 Subject: [PATCH 15/32] re-run CI From 42f7d9fe508bb9cfe84a2b600249018974115bae Mon Sep 17 00:00:00 2001 From: Anthony Dam Date: Wed, 29 Apr 2026 21:30:06 -0700 Subject: [PATCH 16/32] re-run CI From d1b503a3497509641f10668ce478c1b18d07304a Mon Sep 17 00:00:00 2001 From: Anthony Dam Date: Wed, 29 Apr 2026 21:36:49 -0700 Subject: [PATCH 17/32] Fix authorize_instructor to include also TAs and leadTAs --- app/controllers/user_to_courses_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/user_to_courses_controller.rb b/app/controllers/user_to_courses_controller.rb index d06d414b..e339fb7d 100644 --- a/app/controllers/user_to_courses_controller.rb +++ b/app/controllers/user_to_courses_controller.rb @@ -38,7 +38,7 @@ def set_enrollment def authorize_instructor! user_to_course = UserToCourse.find_by(user: @current_user, course: @course) - unless user_to_course&.teacher? + unless user_to_course&.course_admin? render json: { success: false, error: 'Forbidden', From 76bb6525eeeda4a9881c367daa12fb8695e106b6 Mon Sep 17 00:00:00 2001 From: Anthony Dam Date: Wed, 29 Apr 2026 21:40:03 -0700 Subject: [PATCH 18/32] Fix rubycop expecting lms id --- app/controllers/user_to_courses_controller.rb | 2 +- spec/controllers/requests_controller_spec.rb | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/controllers/user_to_courses_controller.rb b/app/controllers/user_to_courses_controller.rb index e339fb7d..1702c256 100644 --- a/app/controllers/user_to_courses_controller.rb +++ b/app/controllers/user_to_courses_controller.rb @@ -12,7 +12,7 @@ def toggle_allow_extended_requests success: false, errors: @enrollment.errors.full_messages, redirect_to: courses_path - }, status: :unprocessable_entity + }, status: :unprocessable_content end end diff --git a/spec/controllers/requests_controller_spec.rb b/spec/controllers/requests_controller_spec.rb index 7ac50ad6..b0f7078f 100644 --- a/spec/controllers/requests_controller_spec.rb +++ b/spec/controllers/requests_controller_spec.rb @@ -438,10 +438,11 @@ end before do + Lms.find_or_create_by(id: 1) { |l| l.lms_name = 'Canvas'; l.use_auth_token = true } session[:user_id] = instructor.canvas_uid UserToCourse.create!(user: instructor, course: course, role: 'teacher') instructor.lms_credentials.create!( - lms_name: 'canvas', + lms_id: 1, token: 'instructor_token', refresh_token: 'instructor_refresh', expire_time: 1.hour.from_now From 08f24b199122eb76eb13582c05cf88afc2943d63 Mon Sep 17 00:00:00 2001 From: Michael Ball Date: Fri, 26 Jun 2026 21:20:45 +0000 Subject: [PATCH 19/32] Authorize before loading enrollment in UserToCoursesController set_enrollment uses UserToCourse.find, which raises RecordNotFound when the id is bogus. Running it before authorize_instructor! leaks the existence/non-existence of enrollment ids to unauthorized callers and returns 404 instead of 403. Reorder so authorization runs first. Co-Authored-By: Claude Opus 4 --- app/controllers/user_to_courses_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/user_to_courses_controller.rb b/app/controllers/user_to_courses_controller.rb index 1702c256..ef8b0da8 100644 --- a/app/controllers/user_to_courses_controller.rb +++ b/app/controllers/user_to_courses_controller.rb @@ -1,8 +1,8 @@ class UserToCoursesController < ApplicationController before_action :authenticate_user! before_action :set_course - before_action :set_enrollment before_action :authorize_instructor! + before_action :set_enrollment def toggle_allow_extended_requests if @enrollment.update(allow_extended_requests: params[:allow_extended_requests]) From d9ac8ca612c324b0ca8a894975a6c5261529baad Mon Sep 17 00:00:00 2001 From: Michael Ball Date: Fri, 26 Jun 2026 21:21:07 +0000 Subject: [PATCH 20/32] Preserve nil refresh_token for developer login Storing the literal string 'none' as a refresh token caused later token-refresh attempts to send refresh_token=none to Canvas and fail with an opaque 4xx. Persist nil instead so refresh code paths can detect the absence and prompt the user to re-authenticate. Co-Authored-By: Claude Opus 4 --- app/controllers/session_controller.rb | 3 +-- spec/controllers/session_controller_spec.rb | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index 1cd5317c..a6881585 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -56,12 +56,11 @@ def omniauth_callback # dev provider doesnt have real credentials so its stubbed expires_at = creds.expires_at || 30.days.from_now.to_i - refresh_token = creds.refresh_token || 'none' access_token = OAuth2::AccessToken.new( OAuth2::Client.new('', ''), # client never used – stub creds.token, - refresh_token: refresh_token, + refresh_token: creds.refresh_token, expires_at: expires_at ) diff --git a/spec/controllers/session_controller_spec.rb b/spec/controllers/session_controller_spec.rb index 6acae4d2..7eb709f2 100644 --- a/spec/controllers/session_controller_spec.rb +++ b/spec/controllers/session_controller_spec.rb @@ -154,14 +154,14 @@ expect(enrollment.role).to eq('student') end - it 'stores fake refresh token for developer provider' do + it 'stores credentials for developer provider with nil refresh token' do get :omniauth_callback, params: { provider: 'developer' } user = User.find_by(canvas_uid: 'test@example.com') creds = user.lms_credentials.first - expect(creds.refresh_token).to be_present expect(creds.token).to be_present + expect(creds.refresh_token).to be_nil end end end From d866268790e17d43c685d5d874b19c9d13debf2e Mon Sep 17 00:00:00 2001 From: Michael Ball Date: Fri, 26 Jun 2026 21:21:19 +0000 Subject: [PATCH 21/32] Use URI::MailTo::EMAIL_REGEXP for notification email validation The hand-rolled regex accepted strings like "a@b" with no TLD. URI::MailTo::EMAIL_REGEXP is RFC-aligned and ships with stdlib. Co-Authored-By: Claude Opus 4 --- app/models/course_settings.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/course_settings.rb b/app/models/course_settings.rb index 0ef7cf6c..1e588844 100644 --- a/app/models/course_settings.rb +++ b/app/models/course_settings.rb @@ -59,7 +59,7 @@ class CourseSettings < ApplicationRecord 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/ }, + validates :pending_notification_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }, if: -> { pending_notification_frequency.present? } after_save :create_or_update_gradescope_link From 518e73d07d4ef168ba3eeffccb444da3e44dc76c Mon Sep 17 00:00:00 2001 From: Michael Ball Date: Thu, 2 Jul 2026 03:55:41 +0000 Subject: [PATCH 22/32] Drop Canvas base_date preservation guard Main's aae5b25 switched the Canvas facade to override_assignment_dates=false, so top-level due_at/lock_at are now the base ("Everyone") dates and are trustworthy. The base_date branch no longer receives data, so the guard in SyncAllCourseAssignmentsJob triggered on every update and prevented Canvas date changes from propagating to existing assignments. Remove the guard, the associated base_date attr on Lmss::Canvas::Assignment, and the base_date_present? abstract method on Lmss::BaseAssignment. Co-Authored-By: Claude Opus 4 --- app/jobs/sync_all_course_assignments_job.rb | 16 +------- lib/lmss/base_assignment.rb | 1 - lib/lmss/canvas/assignment.rb | 7 +--- .../sync_all_course_assignments_job_spec.rb | 37 ------------------- 4 files changed, 3 insertions(+), 58 deletions(-) diff --git a/app/jobs/sync_all_course_assignments_job.rb b/app/jobs/sync_all_course_assignments_job.rb index cec3ebd1..d39f9aff 100644 --- a/app/jobs/sync_all_course_assignments_job.rb +++ b/app/jobs/sync_all_course_assignments_job.rb @@ -47,10 +47,8 @@ def sync_assignment(course_to_lms, lms_assignment, results) # Use shared LmsAssignment to populate Assignment assignment.name = lms_assignment.name - unless preserve_existing_dates?(assignment, lms_assignment) - assignment.due_date = lms_assignment.due_date - assignment.late_due_date = lms_assignment.late_due_date - end + assignment.due_date = lms_assignment.due_date + assignment.late_due_date = lms_assignment.late_due_date assignment.external_assignment_id = lms_assignment.id if assignment.new_record? @@ -62,14 +60,4 @@ def sync_assignment(course_to_lms, lms_assignment, results) end assignment.save! end - - private - - def preserve_existing_dates?(assignment, lms_assignment) - return false if assignment.new_record? - return false unless lms_assignment.is_a?(Lmss::Canvas::Assignment) - return false if lms_assignment.base_date_present? - - assignment.due_date.present? || assignment.late_due_date.present? - end end diff --git a/lib/lmss/base_assignment.rb b/lib/lmss/base_assignment.rb index 1ecc38da..26c02376 100644 --- a/lib/lmss/base_assignment.rb +++ b/lib/lmss/base_assignment.rb @@ -4,6 +4,5 @@ def id = raise(NotImplementedError) def name = raise(NotImplementedError) def due_date = raise(NotImplementedError) def late_due_date = raise(NotImplementedError) - def base_date_present? = false end end diff --git a/lib/lmss/canvas/assignment.rb b/lib/lmss/canvas/assignment.rb index 5daf1beb..ae48ecde 100644 --- a/lib/lmss/canvas/assignment.rb +++ b/lib/lmss/canvas/assignment.rb @@ -1,20 +1,15 @@ module Lmss module Canvas class Assignment < BaseAssignment - attr_reader :id, :name, :due_date, :late_due_date, :base_date + attr_reader :id, :name, :due_date, :late_due_date def initialize(data) @id = data['id'] @name = data['name'] - @base_date = data['base_date'] @due_date = extract_date_field(data, 'due_at') @late_due_date = extract_date_field(data, 'lock_at') end - def base_date_present? - @base_date.is_a?(Hash) && @base_date.any? - end - private # The facade fetches assignments with override_assignment_dates=false, so diff --git a/spec/jobs/sync_all_course_assignments_job_spec.rb b/spec/jobs/sync_all_course_assignments_job_spec.rb index a47f6ca7..8dbe98af 100644 --- a/spec/jobs/sync_all_course_assignments_job_spec.rb +++ b/spec/jobs/sync_all_course_assignments_job_spec.rb @@ -112,43 +112,6 @@ end end - context 'when Canvas omits base_date metadata' do - let(:canvas_assignments) do - [ - Lmss::Canvas::Assignment.new( - 'id' => '123', - 'name' => 'Assignment 1', - 'due_at' => '2025-01-30T23:59:00Z', - 'lock_at' => '2025-02-05T23:59:00Z', - 'base_date' => nil - ) - ] - end - - it 'preserves existing due dates for Canvas assignments' do - existing_assignment = create(:assignment, - course_to_lms: course_to_lms, - external_assignment_id: '123', - due_date: DateTime.parse('2025-01-15T23:59:00Z'), - late_due_date: DateTime.parse('2025-01-20T23:59:00Z') - ) - - described_class.perform_now(course_to_lms.id, sync_user.id) - - existing_assignment.reload - expect(existing_assignment.due_date).to eq(DateTime.parse('2025-01-15T23:59:00Z')) - expect(existing_assignment.late_due_date).to eq(DateTime.parse('2025-01-20T23:59:00Z')) - end - - it 'still sets dates for newly imported assignments' do - described_class.perform_now(course_to_lms.id, sync_user.id) - - assignment = Assignment.find_by(external_assignment_id: '123') - expect(assignment.due_date).to eq(DateTime.parse('2025-01-30T23:59:00Z')) - expect(assignment.late_due_date).to eq(DateTime.parse('2025-02-05T23:59:00Z')) - end - end - context 'when sync_user is not staff' do let(:student_user) { course.students.first } From e2ead4780a0c6c74a84b29c84572568b390a37e4 Mon Sep 17 00:00:00 2001 From: Michael Ball Date: Thu, 2 Jul 2026 03:56:07 +0000 Subject: [PATCH 23/32] Gate developer-login button with Rails.env.local? Match the omniauth developer-provider gate in config/initializers/omniauth.rb (Rails.env.local?), so the button appears in test environments too and stays in lockstep with the provider registration. Co-Authored-By: Claude Opus 4 --- app/views/home/index.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb index f356c5f5..15c6b6d4 100644 --- a/app/views/home/index.html.erb +++ b/app/views/home/index.html.erb @@ -23,7 +23,7 @@
<%= link_to 'Login', '/auth/canvas', class: 'btn btn-primary', id: 'login-button-index' %> - <% if Rails.env.development? %> + <% if Rails.env.local? %> <%= link_to 'Developer Login', '/auth/developer', class: 'btn btn-secondary ms-2', id: 'dev-login-button' %> <% end %>
From bcc647de186026663961ade692853135cb8be7c4 Mon Sep 17 00:00:00 2001 From: Michael Ball Date: Thu, 2 Jul 2026 04:07:59 +0000 Subject: [PATCH 24/32] Revert instructor requests table markup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore main's version of the table header markup — the PR's styling changes were out of scope for this branch. Co-Authored-By: Claude Opus 4 --- app/views/requests/instructor_index.html.erb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/views/requests/instructor_index.html.erb b/app/views/requests/instructor_index.html.erb index a67986a6..92b8f4b8 100644 --- a/app/views/requests/instructor_index.html.erb +++ b/app/views/requests/instructor_index.html.erb @@ -37,15 +37,15 @@ data-action="change->requests#toggleSelectAll" aria-label="Select all pending requests"> - Actions - Name - Assignment - Student ID - Requested At - Original Due Date - Requested Due Date - # of Days - Status + Actions + Name + Assignment + Student ID + Requested At + Original Due Date + Requested Due Date + # of Days + Status From d515853836c0860fe58b480b2df9d1732b6a2fe2 Mon Sep 17 00:00:00 2001 From: Michael Ball Date: Thu, 2 Jul 2026 04:08:18 +0000 Subject: [PATCH 25/32] Drop dead LmsCredential comment and unused UserToCourse#teacher? MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The '# LMS must exist' comment was left over after removing the validates :lms_name, presence: true line; belongs_to :lms already enforces this. UserToCourse#teacher? was never called from the codebase — staff? / course_admin? cover the existing use cases. Co-Authored-By: Claude Opus 4 --- app/models/lms_credential.rb | 2 -- app/models/user_to_course.rb | 4 ---- 2 files changed, 6 deletions(-) diff --git a/app/models/lms_credential.rb b/app/models/lms_credential.rb index 7480a5e5..a75fbcfb 100644 --- a/app/models/lms_credential.rb +++ b/app/models/lms_credential.rb @@ -33,6 +33,4 @@ class LmsCredential < ApplicationRecord # Encryption for tokens encrypts :token, :refresh_token - - # LMS must exist end diff --git a/app/models/user_to_course.rb b/app/models/user_to_course.rb index 9a8af215..33b983a5 100644 --- a/app/models/user_to_course.rb +++ b/app/models/user_to_course.rb @@ -60,10 +60,6 @@ def display_role UserToCourse.display_role(role) end - def teacher? - role == 'teacher' - end - def self.roles [ STUDENT_ROLE ] + UserToCourse.staff_roles end From 880e9b289236a982cd930aea6e2a3043016f40a0 Mon Sep 17 00:00:00 2001 From: Michael Ball Date: Thu, 2 Jul 2026 04:08:50 +0000 Subject: [PATCH 26/32] Use normalizes for pending notification fields Replace the pair of before_validation lambdas with Rails 7.1's normalizes declaration, which handles the blank-to-nil coercion on assignment. Keep the before_save cascade that clears the email when frequency is turned off, but add a comment explaining why. The scope can now compare against nil only (no empty strings can persist). Co-Authored-By: Claude Opus 4 --- app/models/course_settings.rb | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/models/course_settings.rb b/app/models/course_settings.rb index aa04d9ce..c04749bd 100644 --- a/app/models/course_settings.rb +++ b/app/models/course_settings.rb @@ -55,10 +55,15 @@ class CourseSettings < ApplicationRecord 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? } + # Empty submissions become "" — coerce to nil so + # `allow_nil` behaves as expected and unset rows compare equal. + normalizes :pending_notification_frequency, :pending_notification_email, with: ->(v) { v.presence } + before_save :ensure_system_user_for_auto_approval + # Clear a stored email when notifications are turned off, so re-enabling + # doesn't silently reuse a stale address. before_save -> { self.pending_notification_email = nil if pending_notification_frequency.nil? } + validate :gradescope_url_is_valid, if: :enable_gradescope? validates :pending_notification_frequency, inclusion: { in: VALID_NOTIFICATION_FREQUENCIES }, allow_nil: true validates :pending_notification_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }, @@ -67,7 +72,7 @@ class CourseSettings < ApplicationRecord scope :with_pending_notifications, ->(frequency) { where(pending_notification_frequency: frequency) - .where.not(pending_notification_email: [ nil, '' ]) + .where.not(pending_notification_email: nil) } def automatic_approval_enabled? From 4f9bc0227fc4c155b22b1df0c1c39ef8abf6df6b Mon Sep 17 00:00:00 2001 From: Michael Ball Date: Thu, 2 Jul 2026 04:09:15 +0000 Subject: [PATCH 27/32] Simplify UserToCoursesController auth filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the controller-local authenticate_user! and set_course overrides — ApplicationController's authenticated! before_action populates current_user, and its protected set_course does the same lookup. Replace the manual UserToCourse lookup + course_admin? check with Course#course_staff?(current_user). Note in a comment why we don't use the inherited ensure_instructor_role (JSON endpoint, redirect semantics don't fit). Co-Authored-By: Claude Opus 4 --- app/controllers/user_to_courses_controller.rb | 39 +++++++------------ 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/app/controllers/user_to_courses_controller.rb b/app/controllers/user_to_courses_controller.rb index ef8b0da8..b5f22aad 100644 --- a/app/controllers/user_to_courses_controller.rb +++ b/app/controllers/user_to_courses_controller.rb @@ -1,7 +1,8 @@ class UserToCoursesController < ApplicationController - before_action :authenticate_user! + # `authenticated!` from ApplicationController runs before this filter, so + # `current_user` is populated by the time we get here. before_action :set_course - before_action :authorize_instructor! + before_action :ensure_course_staff! before_action :set_enrollment def toggle_allow_extended_requests @@ -18,32 +19,20 @@ def toggle_allow_extended_requests private - def authenticate_user! - user_id = session[:user_id] - @current_user = User.find_by(canvas_uid: user_id) if user_id - redirect_to root_path unless @current_user - end - - def set_course - @course = Course.find_by(id: params[:course_id]) - unless @course - flash[:alert] = 'Course not found.' - redirect_to courses_path - end - end - def set_enrollment @enrollment = UserToCourse.find(params[:id]) end - def authorize_instructor! - user_to_course = UserToCourse.find_by(user: @current_user, course: @course) - unless user_to_course&.course_admin? - render json: { - success: false, - error: 'Forbidden', - redirect_to: courses_path - }, status: :forbidden - end + # ApplicationController#ensure_instructor_role would redirect with a flash, + # which breaks the JSON fetch from the course-settings UI. Respond with 403 + # JSON so the client can surface the failure inline. + def ensure_course_staff! + return if @course&.course_staff?(current_user) + + render json: { + success: false, + error: 'Forbidden', + redirect_to: courses_path + }, status: :forbidden end end From 61f989225e22f00e4406a5a368ede76f126ee030 Mon Sep 17 00:00:00 2001 From: Michael Ball Date: Thu, 2 Jul 2026 04:14:39 +0000 Subject: [PATCH 28/32] Extract pending-requests digest into PendingRequestsMailer Pulls the subject/body templates out of the job into constants on a new mailer class (body uses a heredoc). The job now just computes the per-course values and delegates delivery. Co-Authored-By: Claude Opus 4 --- app/jobs/pending_requests_notification_job.rb | 19 +-------- app/mailers/pending_requests_mailer.rb | 40 +++++++++++++++++++ 2 files changed, 41 insertions(+), 18 deletions(-) create mode 100644 app/mailers/pending_requests_mailer.rb diff --git a/app/jobs/pending_requests_notification_job.rb b/app/jobs/pending_requests_notification_job.rb index f94faa22..0d5d4eaf 100644 --- a/app/jobs/pending_requests_notification_job.rb +++ b/app/jobs/pending_requests_notification_job.rb @@ -9,24 +9,7 @@ def perform(frequency) 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 - ) + PendingRequestsMailer.send_pending_request_notifications(cs, pending_count, requests_url) end end end diff --git a/app/mailers/pending_requests_mailer.rb b/app/mailers/pending_requests_mailer.rb new file mode 100644 index 00000000..8f5b9e2f --- /dev/null +++ b/app/mailers/pending_requests_mailer.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# Templates and delivery helper for the "you have N pending extension requests" +# digest email that PendingRequestsNotificationJob sends. Wraps EmailService so +# the job doesn't need to know the template strings or mapping shape. +class PendingRequestsMailer + SUBJECT_TEMPLATE = '{{pending_count}} Pending Extension Request{{plural}} - {{course_code}}' + + BODY_TEMPLATE = <<~BODY + Hello, + + You have {{pending_count}} pending extension request{{plural}} in {{course_name}} ({{course_code}}). + + Please review them at: {{requests_url}} + + Thank you, + Flextensions + BODY + + def self.send_pending_request_notifications(course_settings, pending_count, requests_url) + course = course_settings.course + default_from = ENV.fetch('DEFAULT_FROM_EMAIL') + + EmailService.send_email( + to: course_settings.pending_notification_email, + from: default_from, + reply_to: course_settings.reply_email.presence || default_from, + subject_template: SUBJECT_TEMPLATE, + body_template: BODY_TEMPLATE, + mapping: { + 'pending_count' => pending_count.to_s, + 'plural' => pending_count == 1 ? '' : 's', + 'course_name' => course.course_name, + 'course_code' => course.course_code, + 'requests_url' => requests_url + }, + deliver_later: false + ) + end +end From 2293bd6fc49905fd4095fc15672b6d7411a442e6 Mon Sep 17 00:00:00 2001 From: Michael Ball Date: Sat, 4 Jul 2026 10:33:19 +0000 Subject: [PATCH 29/32] Merge remote-tracking branch 'origin/main' into cycomachead/97-preview-only-pr-lms-id-fix/2 Resolve conflicts between the lms-id-fix branch and main's Rails 8.1 upgrade, UserToCourse->Enrollment rename, 1:1 course-settings refactor, and Canvas auto-approval reliability work: - Keep main's LmsCredential#expires_soon?/#refresh! (drop-dead-credential handling) and adapt them to the lms_id foreign key: the lms_name column is gone, so the presence validation is dropped (belongs_to :lms covers it) and log messages read the name via the lms association. - Port the lms_credentials:purge rake task and its spec to join lmss instead of filtering on the removed lms_name column. - EnrollmentsController: keep the branch's staff-authorization-before-load and JSON 403 shape, with main's class name and course-scoped lookup. - CourseSettings: keep both main's course_id uniqueness validation and the branch's pending-notification normalization/validations/scope; add the pending notification params to main's params.expect list. - Drop the course_settings factory (main deleted it; settings are now auto-created with each course) and update branch specs to mutate course.course_settings instead of creating rows/factories. - Adapt main's expanded staff-failover and token-refresh specs to create credentials via lms_id; drop the branch's tests for the removed User#token_expired?. - Regenerate db/schema.rb via migration replay on top of main's 8.1 dump. Co-Authored-By: Claude Fable 5 --- .github/workflows/a11y.yml | 38 +- .github/workflows/build.yml | 2 +- .github/workflows/cucumber.yml | 2 +- .github/workflows/rspec.yml | 2 +- .github/workflows/rubocop.yml | 2 +- .github/workflows/swagger_validate.yml | 4 +- Gemfile | 7 +- Gemfile.lock | 181 +++++----- app/assets/stylesheets/_custom_bootstrap.scss | 53 ++- app/assets/stylesheets/application.scss | 17 + .../datatables/dataTables.bootstrap5.css | 21 +- app/controllers/api/v1/courses_controller.rb | 10 +- app/controllers/application_controller.rb | 6 +- app/controllers/concerns/token_refreshable.rb | 46 +-- app/controllers/course_settings_controller.rb | 40 ++- app/controllers/courses_controller.rb | 38 +- ...ontroller.rb => enrollments_controller.rb} | 4 +- app/controllers/form_settings_controller.rb | 10 +- .../requests/exports_controller.rb | 19 + app/controllers/requests_controller.rb | 182 ++++------ app/controllers/session_controller.rb | 5 +- app/facades/canvas_facade.rb | 9 +- app/facades/gradescope_facade.rb | 4 + app/jobs/sync_users_from_canvas_job.rb | 14 +- app/mailers/application_mailer.rb | 5 + app/mailers/templated_mailer.rb | 17 + app/models/assignment.rb | 18 +- app/models/course.rb | 113 +++--- app/models/course_settings.rb | 20 +- app/models/course_to_lms.rb | 4 + .../{user_to_course.rb => enrollment.rb} | 17 +- app/models/lms_credential.rb | 49 +++ app/models/request.rb | 124 +++++-- app/models/user.rb | 49 +-- app/services/assignment_date_calculator.rb | 15 +- app/services/email_service.rb | 5 +- app/services/request_service.rb | 57 --- app/services/slack_notifier.rb | 1 + app/views/courses/edit.html.erb | 38 +- app/views/courses/enrollments.html.erb | 2 +- app/views/courses/index.html.erb | 18 +- app/views/courses/new.html.erb | 4 +- .../shared/_extension_status_warning.html.erb | 7 +- config/application.rb | 2 +- config/initializers/faultline.rb | 282 +++++++++++++++ config/initializers/lograge.rb | 5 +- config/routes.rb | 5 +- db/api_spec_seeds.rb | 4 +- ...0702000001_add_course_id_to_assignments.rb | 25 ++ ...702035829_create_faultline_error_groups.rb | 57 +++ ...5830_create_faultline_error_occurrences.rb | 36 ++ ...2035831_create_faultline_error_contexts.rb | 15 + ...2035832_create_faultline_request_traces.rb | 22 ++ ...3_add_spans_to_faultline_request_traces.rb | 13 + ...35834_create_faultline_request_profiles.rb | 15 + ...035835_change_sanitized_message_to_text.rb | 31 ++ ...0001_enforce_one_to_one_course_settings.rb | 35 ++ ...1_rename_user_to_courses_to_enrollments.rb | 5 + db/schema.rb | 267 +++++++++----- db/seeds.rb | 14 +- features/step_definitions/custom_steps.rb | 12 +- lib/tasks/backfill_extend_late_due_date.rake | 35 -- lib/tasks/lms_credentials.rake | 14 + public/400.html | 114 ++++++ .../api/v1/assignments_controller_spec.rb | 4 +- .../api/v1/lmss_controller_spec.rb | 4 +- .../assignments_controller_spec.rb | 12 +- .../concerns/token_refreshable_spec.rb | 35 +- .../course_settings_controller_spec.rb | 43 +-- spec/controllers/courses_controller_spec.rb | 39 +- ...spec.rb => enrollments_controller_spec.rb} | 14 +- .../form_settings_controller_spec.rb | 4 +- spec/controllers/requests_controller_spec.rb | 160 +++++++-- spec/controllers/session_controller_spec.rb | 2 +- spec/factories/course_settings.rb | 52 --- spec/factories/courses.rb | 4 +- .../{user_to_course.rb => enrollment.rb} | 2 +- spec/factories/users.rb | 8 +- spec/features/accessibility_spec.rb | 30 +- spec/models/assignment_spec.rb | 20 ++ spec/models/course_settings_spec.rb | 13 +- spec/models/course_spec.rb | 149 +++++++- ...r_to_course_spec.rb => enrollment_spec.rb} | 20 +- spec/models/lms_credential_spec.rb | 104 ++++++ spec/models/request_spec.rb | 338 ++++++++++++------ spec/models/user_spec.rb | 85 ++--- spec/requests/request_exports_spec.rb | 63 ++++ .../assignment_date_calculator_spec.rb | 59 +-- spec/tasks/lms_credentials_rake_spec.rb | 44 +++ spec/tasks/notifications_rake_spec.rb | 2 +- utils/canvas_sandbox/README.md | 57 +++ utils/canvas_sandbox/setup_test_course.rb | 124 +++++++ utils/canvas_sandbox/test_approval_flows.rb | 270 ++++++++++++++ 93 files changed, 2880 insertions(+), 1168 deletions(-) rename app/controllers/{user_to_courses_controller.rb => enrollments_controller.rb} (90%) create mode 100644 app/controllers/requests/exports_controller.rb create mode 100644 app/mailers/application_mailer.rb create mode 100644 app/mailers/templated_mailer.rb rename app/models/{user_to_course.rb => enrollment.rb} (83%) delete mode 100644 app/services/request_service.rb create mode 100644 config/initializers/faultline.rb create mode 100644 db/migrate/20260702000001_add_course_id_to_assignments.rb create mode 100644 db/migrate/20260702035829_create_faultline_error_groups.rb create mode 100644 db/migrate/20260702035830_create_faultline_error_occurrences.rb create mode 100644 db/migrate/20260702035831_create_faultline_error_contexts.rb create mode 100644 db/migrate/20260702035832_create_faultline_request_traces.rb create mode 100644 db/migrate/20260702035833_add_spans_to_faultline_request_traces.rb create mode 100644 db/migrate/20260702035834_create_faultline_request_profiles.rb create mode 100644 db/migrate/20260702035835_change_sanitized_message_to_text.rb create mode 100644 db/migrate/20260703000001_enforce_one_to_one_course_settings.rb create mode 100644 db/migrate/20260704000001_rename_user_to_courses_to_enrollments.rb delete mode 100644 lib/tasks/backfill_extend_late_due_date.rake create mode 100644 lib/tasks/lms_credentials.rake create mode 100644 public/400.html rename spec/controllers/{user_to_courses_controller_spec.rb => enrollments_controller_spec.rb} (89%) delete mode 100644 spec/factories/course_settings.rb rename spec/factories/{user_to_course.rb => enrollment.rb} (88%) rename spec/models/{user_to_course_spec.rb => enrollment_spec.rb} (73%) create mode 100644 spec/requests/request_exports_spec.rb create mode 100644 spec/tasks/lms_credentials_rake_spec.rb create mode 100644 utils/canvas_sandbox/README.md create mode 100644 utils/canvas_sandbox/setup_test_course.rb create mode 100644 utils/canvas_sandbox/test_approval_flows.rb diff --git a/.github/workflows/a11y.yml b/.github/workflows/a11y.yml index fdb6c6dd..33bc9d11 100644 --- a/.github/workflows/a11y.yml +++ b/.github/workflows/a11y.yml @@ -16,7 +16,7 @@ jobs: POSTGRES_USER: postgres POSTGRES_PASSWORD: password ports: - - 5432:5432 + - 5432:5432 options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 env: # Do not install production/staging-only gems in test environment @@ -28,23 +28,29 @@ jobs: steps: - name: Install packages run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: .tool-versions bundler-cache: true - - name: Set up Database - run: bundle exec rails db:setup - - name: Create separate Chrome data directories - run: | - mkdir -p /tmp/chrome-data-rspec - - name: Run RSpec Accessibility Tests - run: bundle exec rspec spec/ --tag a11y - env: - CHROME_DATA_DIR: /tmp/chrome-data-rspec - - name: Upload Screenshot - uses: actions/upload-artifact@v2 - with: - name: screenshot - path: /tmp/chrome-data-rspec/screenshots + - name: Set up Database + run: bundle exec rails db:setup + - name: Create separate Chrome data directories + run: | + mkdir -p /tmp/chrome-data-rspec + - name: Run RSpec Accessibility Tests + run: bundle exec rspec spec/ --tag a11y + env: + CHROME_DATA_DIR: /tmp/chrome-data-rspec + # The specs call page.save_screenshot(name) before each axe assertion, so + # Capybara writes one screenshot per page/theme/role to its default save + # path (tmp/capybara) — including for the pages that fail. Upload the whole + # directory on failure so the failing screenshots are available to inspect. + - name: Upload a11y failure screenshots + if: failure() + uses: actions/upload-artifact@v4 + with: + name: a11y-failure-screenshots + path: tmp/capybara/ + if-no-files-found: ignore diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c8bf6408..25ba57e6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,7 +33,7 @@ jobs: steps: - name: Install packages run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: diff --git a/.github/workflows/cucumber.yml b/.github/workflows/cucumber.yml index 07b33c93..7c964f02 100644 --- a/.github/workflows/cucumber.yml +++ b/.github/workflows/cucumber.yml @@ -31,7 +31,7 @@ jobs: steps: - name: Install packages run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: diff --git a/.github/workflows/rspec.yml b/.github/workflows/rspec.yml index f2b4ef61..2c8dba1a 100644 --- a/.github/workflows/rspec.yml +++ b/.github/workflows/rspec.yml @@ -31,7 +31,7 @@ jobs: steps: # - name: Install packages # run: sudo apt-get update - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: diff --git a/.github/workflows/rubocop.yml b/.github/workflows/rubocop.yml index 2142145b..b42c5e5d 100644 --- a/.github/workflows/rubocop.yml +++ b/.github/workflows/rubocop.yml @@ -29,7 +29,7 @@ jobs: BUNDLE_WITHOUT: "production staging default test" name: Ruby Linting steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: diff --git a/.github/workflows/swagger_validate.yml b/.github/workflows/swagger_validate.yml index feb975a1..a11835a6 100644 --- a/.github/workflows/swagger_validate.yml +++ b/.github/workflows/swagger_validate.yml @@ -10,9 +10,9 @@ jobs: runs-on: ubuntu-latest name: Validate Spec steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: '20' - name: Install validation tools diff --git a/Gemfile b/Gemfile index 9f7eed60..c542f977 100644 --- a/Gemfile +++ b/Gemfile @@ -3,7 +3,7 @@ source 'https://rubygems.org' ruby '~> 3.3' # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" -gem 'rails', '~> 7.2.3.1' +gem 'rails', '~> 8.1.0' # Use postgres for all env dbs gem 'pg' @@ -57,6 +57,9 @@ gem 'omniauth-oauth2' # Audit for potentially unsafe database migrations gem 'strong_migrations' +# Self-hosted error tracking. +gem 'faultline', git: 'https://github.com/dlt/faultline.git' + # Logging Customization gem 'lograge' @@ -114,7 +117,7 @@ group :test do gem 'rack_session_access' gem 'rspec-retry' gem 'selenium-webdriver' - gem 'shoulda-matchers', '~> 7.0' + gem 'shoulda-matchers', '~> 8.0' gem 'simplecov', '~> 0.22.0', require: false gem 'simplecov_json_formatter' gem 'timecop' diff --git a/Gemfile.lock b/Gemfile.lock index da616c41..d70cb5cb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,79 +1,88 @@ +GIT + remote: https://github.com/dlt/faultline.git + revision: 711362c44b14483c1748b4acff8cd9aa773e3e59 + specs: + faultline (0.1.0) + mcp (~> 0.15) + rails (>= 8.0) + GEM remote: https://rubygems.org/ specs: - actioncable (7.2.3.1) - actionpack (= 7.2.3.1) - activesupport (= 7.2.3.1) + action_text-trix (2.1.19) + railties + actioncable (8.1.3) + actionpack (= 8.1.3) + activesupport (= 8.1.3) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.2.3.1) - actionpack (= 7.2.3.1) - activejob (= 7.2.3.1) - activerecord (= 7.2.3.1) - activestorage (= 7.2.3.1) - activesupport (= 7.2.3.1) + actionmailbox (8.1.3) + actionpack (= 8.1.3) + activejob (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) mail (>= 2.8.0) - actionmailer (7.2.3.1) - actionpack (= 7.2.3.1) - actionview (= 7.2.3.1) - activejob (= 7.2.3.1) - activesupport (= 7.2.3.1) + actionmailer (8.1.3) + actionpack (= 8.1.3) + actionview (= 8.1.3) + activejob (= 8.1.3) + activesupport (= 8.1.3) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.2.3.1) - actionview (= 7.2.3.1) - activesupport (= 7.2.3.1) - cgi + actionpack (8.1.3) + actionview (= 8.1.3) + activesupport (= 8.1.3) nokogiri (>= 1.8.5) - racc - rack (>= 2.2.4, < 3.3) + rack (>= 2.2.4) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (7.2.3.1) - actionpack (= 7.2.3.1) - activerecord (= 7.2.3.1) - activestorage (= 7.2.3.1) - activesupport (= 7.2.3.1) + actiontext (8.1.3) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.2.3.1) - activesupport (= 7.2.3.1) + actionview (8.1.3) + activesupport (= 8.1.3) builder (~> 3.1) - cgi erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.2.3.1) - activesupport (= 7.2.3.1) + activejob (8.1.3) + activesupport (= 8.1.3) globalid (>= 0.3.6) - activemodel (7.2.3.1) - activesupport (= 7.2.3.1) - activerecord (7.2.3.1) - activemodel (= 7.2.3.1) - activesupport (= 7.2.3.1) + activemodel (8.1.3) + activesupport (= 8.1.3) + activerecord (8.1.3) + activemodel (= 8.1.3) + activesupport (= 8.1.3) timeout (>= 0.4.0) - activestorage (7.2.3.1) - actionpack (= 7.2.3.1) - activejob (= 7.2.3.1) - activerecord (= 7.2.3.1) - activesupport (= 7.2.3.1) + activestorage (8.1.3) + actionpack (= 8.1.3) + activejob (= 8.1.3) + activerecord (= 8.1.3) + activesupport (= 8.1.3) marcel (~> 1.0) - activesupport (7.2.3.1) + activesupport (8.1.3) base64 - benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + json logger (>= 1.4.2) - minitest (>= 5.1, < 6) + minitest (>= 5.1) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) annotaterb (4.23.0) @@ -87,7 +96,6 @@ GEM axe-core-rspec (4.12.0) axe-core-api (= 4.12.0) base64 (0.3.0) - benchmark (0.5.0) bigdecimal (4.1.2) bindex (0.8.1) blazer (3.4.0) @@ -115,7 +123,6 @@ GEM capybara-screenshot (1.0.27) capybara (>= 1.0, < 4) launchy - cgi (0.5.2) chartkick (5.2.1) childprocess (5.1.0) logger (~> 1.5) @@ -127,7 +134,7 @@ GEM crack (1.0.1) bigdecimal rexml - crass (1.0.6) + crass (1.0.7) csv (3.3.5) cucumber (10.2.0) base64 (~> 0.2) @@ -203,7 +210,7 @@ GEM sassc (~> 2.0) formatador (1.2.3) reline - globalid (1.3.0) + globalid (1.4.0) activesupport (>= 6.1) guard (2.20.1) formatador (>= 0.2.4) @@ -220,6 +227,7 @@ GEM guard (~> 2.1) guard-compat (~> 1.1) rspec (>= 2.99.0, < 4.0) + hana (1.3.7) hashdiff (1.2.1) hashie (5.1.0) logger @@ -247,6 +255,11 @@ GEM actionview (>= 7.0.0) activesupport (>= 7.0.0) json (2.20.0) + json_schemer (2.5.0) + bigdecimal + hana (~> 1.3) + regexp_parser (~> 2.0) + simpleidn (~> 0.2) jwt (3.2.0) base64 language_server-protocol (3.17.0.5) @@ -279,19 +292,23 @@ GEM crass (~> 1.0.2) nokogiri (>= 1.12.0) lumberjack (1.4.2) - mail (2.9.0) + mail (2.9.1) logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp - marcel (1.1.0) + marcel (1.2.1) matrix (0.4.3) + mcp (0.22.0) + json_schemer (>= 2.4) memoist3 (1.0.0) method_source (1.1.0) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.27.0) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) msgpack (1.8.3) multi_test (1.1.0) multi_xml (0.8.1) @@ -363,9 +380,6 @@ GEM coderay (~> 1.1) method_source (~> 1.0) reline (>= 0.6.0) - psych (5.4.0) - date - stringio public_suffix (7.0.5) puma (8.0.2) nio4r (~> 2.0) @@ -385,20 +399,20 @@ GEM rack (>= 1.0.0) rackup (2.3.1) rack (>= 3) - rails (7.2.3.1) - actioncable (= 7.2.3.1) - actionmailbox (= 7.2.3.1) - actionmailer (= 7.2.3.1) - actionpack (= 7.2.3.1) - actiontext (= 7.2.3.1) - actionview (= 7.2.3.1) - activejob (= 7.2.3.1) - activemodel (= 7.2.3.1) - activerecord (= 7.2.3.1) - activestorage (= 7.2.3.1) - activesupport (= 7.2.3.1) + rails (8.1.3) + actioncable (= 8.1.3) + actionmailbox (= 8.1.3) + actionmailer (= 8.1.3) + actionpack (= 8.1.3) + actiontext (= 8.1.3) + actionview (= 8.1.3) + activejob (= 8.1.3) + activemodel (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) bundler (>= 1.15.0) - railties (= 7.2.3.1) + railties (= 8.1.3) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -410,10 +424,9 @@ GEM rails-html-sanitizer (1.7.0) loofah (~> 2.25) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (7.2.3.1) - actionpack (= 7.2.3.1) - activesupport (= 7.2.3.1) - cgi + railties (8.1.3) + actionpack (= 8.1.3) + activesupport (= 8.1.3) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -425,9 +438,14 @@ GEM rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) - rdoc (7.2.0) + rbs (4.0.3) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) erb - psych (>= 4.0.0) + prism (>= 1.6.0) + rbs (>= 4.0.0) tsort regexp_parser (2.12.0) reline (0.6.3) @@ -458,7 +476,7 @@ GEM rspec-retry (0.6.2) rspec-core (> 3.3) rspec-support (3.13.7) - rubocop (1.86.2) + rubocop (1.88.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -491,7 +509,7 @@ GEM regexp_parser (>= 2.0) rubocop (~> 1.86, >= 1.86.2) ruby-progressbar (1.13.0) - rubyzip (3.3.0) + rubyzip (3.4.1) safely_block (1.0.0) sassc (2.4.0) ffi (~> 1.9) @@ -502,7 +520,7 @@ GEM sprockets-rails tilt securerandom (0.4.1) - selenium-webdriver (4.44.0) + selenium-webdriver (4.45.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -516,14 +534,15 @@ GEM concurrent-ruby (~> 1.0, >= 1.0.2) logger shellany (0.0.1) - shoulda-matchers (7.0.1) - activesupport (>= 7.1) + shoulda-matchers (8.0.1) + activesupport (>= 7.2) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + simpleidn (0.2.3) snaky_hash (2.0.3) hashie (>= 0.1.0, < 6) version_gem (>= 1.1.8, < 3) @@ -537,7 +556,6 @@ GEM sprockets (>= 3.0.0) stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.2.0) strong_migrations (2.8.0) activerecord (>= 7.2) sys-uname (1.5.1) @@ -575,7 +593,7 @@ GEM crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) websocket (1.2.11) - websocket-driver (0.8.0) + websocket-driver (0.8.2) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -611,6 +629,7 @@ DEPENDENCIES factory_bot_rails faraday faraday-cookie_jar + faultline! font-awesome-sass guard-rspec hypershield @@ -628,7 +647,7 @@ DEPENDENCIES pg puma (>= 6.0) rack_session_access - rails (~> 7.2.3.1) + rails (~> 8.1.0) rails-controller-testing (~> 1.0) rspec-rails rspec-retry @@ -640,7 +659,7 @@ DEPENDENCIES selenium-webdriver sentry-rails sentry-ruby - shoulda-matchers (~> 7.0) + shoulda-matchers (~> 8.0) simplecov (~> 0.22.0) simplecov_json_formatter sprockets-rails diff --git a/app/assets/stylesheets/_custom_bootstrap.scss b/app/assets/stylesheets/_custom_bootstrap.scss index 2173cbdc..66d2e90c 100644 --- a/app/assets/stylesheets/_custom_bootstrap.scss +++ b/app/assets/stylesheets/_custom_bootstrap.scss @@ -51,12 +51,18 @@ } code { - color: #be0303 !important; + // Loaded after Bootstrap, so this overrides --bs-code-color by source order. + color: #be0303; } // Fix for dark theme contrast issues [data-bs-theme="dark"] { + // Drop the default page background one notch below Bootstrap's #212529 for a + // deeper dark theme. This only increases contrast for foreground content. + --bs-body-bg: #1a1d20; + --bs-body-bg-rgb: 26, 29, 32; + #login-button-index { color: #000000 !important; } @@ -179,11 +185,56 @@ code { h2, h3, h4, h5, h6, p, label, .form-label, strong, li { color: #212529 !important; } + + // These regions keep a light background in dark mode, so must stay + // dark-red here (the lighter tint below is only for the dark surface). + // Higher specificity than the plain dark-surface `code` rule below. + code, .js-copytext { + color: #be0303; + } } #import-courses-button { color: #000000 !important; } + + // Outline buttons: Bootstrap keeps the brand color as the text/border color, + // but berkeley-blue (#002676) and the default danger red are far too dark to + // read on the dark surface (#212529). Swap to lighter tints that clear WCAG + // AA, and use dark text on the filled hover/active state. + .btn-outline-primary { + --bs-btn-color: #6ea8fe; + --bs-btn-border-color: #6ea8fe; + --bs-btn-hover-color: #000000; + --bs-btn-hover-bg: #6ea8fe; + --bs-btn-hover-border-color: #6ea8fe; + --bs-btn-active-color: #000000; + --bs-btn-active-bg: #6ea8fe; + --bs-btn-active-border-color: #6ea8fe; + --bs-btn-disabled-color: #6ea8fe; + --bs-btn-disabled-border-color: #6ea8fe; + } + + .btn-outline-danger { + --bs-btn-color: #ea868f; + --bs-btn-border-color: #ea868f; + --bs-btn-hover-color: #000000; + --bs-btn-hover-bg: #ea868f; + --bs-btn-hover-border-color: #ea868f; + --bs-btn-active-color: #000000; + --bs-btn-active-bg: #ea868f; + --bs-btn-active-border-color: #ea868f; + --bs-btn-disabled-color: #ea868f; + --bs-btn-disabled-border-color: #ea868f; + } + + // Inline (and the copy-to-clipboard code snippets) use a dark red that + // fails contrast on the dark surface. Lighten it to match the danger tint. + // (`.bg-light code` above keeps the dark red where the surface stays light.) + code, + .js-copytext { + color: #ea868f; + } } .b-example-divider { diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 4a5b7441..b1ff17d5 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -125,3 +125,20 @@ $secondary: $california-gold; } } } + +// Section-header rows (e.g. the semester dividers on the course dashboards) use +// a solid `.table-blue-light` variant. Opt them out of Bootstrap's zebra +// striping so the striped overlay can't override their background/text colors +// and drop contrast below WCAG AA — most visibly in dark mode, where the +// striped text color flips to white and became unreadable on the light-blue +// header. `initial` resets these custom properties so Bootstrap's var() chain +// falls back to the variant's own --bs-table-bg / --bs-table-color. +// +// The two selectors mirror Bootstrap's and DataTables' striped selectors so +// they match their specificity and win by source order (this file is compiled +// after both) — no !important required. +.table-striped > tbody > tr.table-blue-light > *, +table.table.dataTable.table-striped > tbody > tr.table-blue-light > * { + --bs-table-bg-type: initial; + --bs-table-color-type: initial; +} diff --git a/app/assets/stylesheets/datatables/dataTables.bootstrap5.css b/app/assets/stylesheets/datatables/dataTables.bootstrap5.css index ed11e6ea..e1875097 100644 --- a/app/assets/stylesheets/datatables/dataTables.bootstrap5.css +++ b/app/assets/stylesheets/datatables/dataTables.bootstrap5.css @@ -1,3 +1,8 @@ +/* NOTE: This vendored DataTables stylesheet is compiled by sassc-rails. + libsass evaluates rgb()/rgba() as Sass functions and cannot parse + rgb(var(--x)); each such value is wrapped in unquote("...") so it is + emitted verbatim for the browser while preserving the CSS variables + (which flip between light/dark themes). */ :root { --dt-row-selected: 13, 110, 253; --dt-row-selected-text: 255, 255, 255; @@ -214,7 +219,7 @@ div.dt-processing > div:last-child > div { height: 13px; border-radius: 50%; background: rgb(13, 110, 253); - background: rgb(var(--dt-row-selected)); + background: unquote("rgb(var(--dt-row-selected))"); animation-timing-function: cubic-bezier(0, 1, 1, 0); } div.dt-processing > div:last-child > div:nth-child(1) { @@ -399,27 +404,27 @@ table.table.dataTable > tbody > tr { } table.table.dataTable > tbody > tr.selected > * { box-shadow: inset 0 0 0 9999px rgb(13, 110, 253); - box-shadow: inset 0 0 0 9999px rgb(var(--dt-row-selected)); + box-shadow: inset 0 0 0 9999px unquote("rgb(var(--dt-row-selected))"); color: rgb(255, 255, 255); - color: rgb(var(--dt-row-selected-text)); + color: unquote("rgb(var(--dt-row-selected-text))"); } table.table.dataTable > tbody > tr.selected a { color: rgb(9, 10, 11); - color: rgb(var(--dt-row-selected-link)); + color: unquote("rgb(var(--dt-row-selected-link))"); } table.table.dataTable.table-striped > tbody > tr:nth-of-type(2n+1) > * { - box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-stripe), 0.05); + box-shadow: inset 0 0 0 9999px unquote("rgba(var(--dt-row-stripe), 0.05)"); } table.table.dataTable.table-striped > tbody > tr:nth-of-type(2n+1).selected > * { box-shadow: inset 0 0 0 9999px rgba(13, 110, 253, 0.95); - box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.95); + box-shadow: inset 0 0 0 9999px unquote("rgba(var(--dt-row-selected), 0.95)"); } table.table.dataTable.table-hover > tbody > tr:hover > * { - box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-hover), 0.075); + box-shadow: inset 0 0 0 9999px unquote("rgba(var(--dt-row-hover), 0.075)"); } table.table.dataTable.table-hover > tbody > tr.selected:hover > * { box-shadow: inset 0 0 0 9999px rgba(13, 110, 253, 0.975); - box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.975); + box-shadow: inset 0 0 0 9999px unquote("rgba(var(--dt-row-selected), 0.975)"); } div.dt-container div.dt-layout-start > *:not(:last-child) { diff --git a/app/controllers/api/v1/courses_controller.rb b/app/controllers/api/v1/courses_controller.rb index 6a40f029..91d20d88 100644 --- a/app/controllers/api/v1/courses_controller.rb +++ b/app/controllers/api/v1/courses_controller.rb @@ -43,16 +43,16 @@ def add_user end # Check if the user has been already added to the course - existing_user_to_course = UserToCourse.find_by(course_id: course_id, user_id: user_id) - if existing_user_to_course + existing_enrollment = Enrollment.find_by(course_id: course_id, user_id: user_id) + if existing_enrollment render json: { error: 'The user is already added to the course.' }, status: :unprocessable_content return end # Add the user to the course with the desired role - new_user_to_course = UserToCourse.new(course_id: course_id, user_id: user_id, role: role) - new_user_to_course.save - render_response(new_user_to_course, + new_enrollment = Enrollment.new(course_id: course_id, user_id: user_id, role: role) + new_enrollment.save + render_response(new_enrollment, 'User added to the course successfully.', 'Failed to add the user the to course.') end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 0e99c74c..0daf0722 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -12,7 +12,7 @@ def excluded_controller_action? 'login' => [ 'canvas' ], 'session' => %w[create omniauth_callback omniauth_failure], 'rails/health' => [ 'show' ], - 'requests' => [ 'export' ] + 'requests/exports' => [ 'show' ] } controller = params[:controller] action = params[:action] @@ -77,6 +77,8 @@ def handle_authentication_failure(message) def handle_lms_api_error(error) Rails.logger.error "LMS API Error: #{error.message}" + Rails.error.report(error, handled: true, + context: { component: 'lms_api', controller: controller_name, action: action_name }) # Truncate to 1K characters so we are well short of cookie limits. error_message = error.message.truncate(1000) flash[:alert] = "An error occurred while communicating with the LMS. Please reach out to flextension@berkeley.edu if you continue to have trouble. Error: #{error_message}" @@ -128,7 +130,7 @@ def set_course end def ensure_instructor_role - return if @role == 'instructor' + return if @user && @course&.course_staff?(@user) flash[:alert] = 'You do not have access to this page.' redirect_to courses_path diff --git a/app/controllers/concerns/token_refreshable.rb b/app/controllers/concerns/token_refreshable.rb index b8d7f698..9e141a85 100644 --- a/app/controllers/concerns/token_refreshable.rb +++ b/app/controllers/concerns/token_refreshable.rb @@ -3,55 +3,19 @@ module TokenRefreshable # Ensure user has a valid token before making API calls def with_valid_token(user) - return yield(user.lms_credentials.first.token) unless user.token_expires_soon? + token = user.ensure_fresh_canvas_token! - # Token is expiring soon, refresh it - new_token = refresh_user_token(user) - - if new_token - # Return the block with the new token - yield(new_token) - else - # Token refresh failed + if token.nil? Rails.logger.error "Failed to refresh token for user #{user.id}" raise 'Invalid authentication token' end + + yield(token) end private - # This needs to be moved to the user model or CanvasFacade def refresh_user_token(user) - # Get the user's credentials - credential = user.lms_credentials.first - return unless credential&.refresh_token - - # TODO: Debug / test API scopes. - client = OAuth2::Client.new( - ENV.fetch('CANVAS_CLIENT_ID', nil), - ENV.fetch('CANVAS_APP_KEY', nil), - site: ENV.fetch('CANVAS_URL', nil), - token_url: '/login/oauth2/token' - ) - - # Use refresh token to get a new access token - begin - token = OAuth2::AccessToken.from_hash( - client, - refresh_token: credential.refresh_token - ).refresh! - - # Update the user's credentials with the new token - credential.update( - token: token.token, - refresh_token: token.refresh_token || credential.refresh_token, # Keep old refresh token if new one not provided - expire_time: Time.zone.at(token.expires_at) - ) - - token.token - rescue OAuth2::Error => e - Rails.logger.error "Failed to refresh token: #{e.message}" - nil - end + user.canvas_credentials&.refresh! end end diff --git a/app/controllers/course_settings_controller.rb b/app/controllers/course_settings_controller.rb index 9e410074..1bf3f47f 100644 --- a/app/controllers/course_settings_controller.rb +++ b/app/controllers/course_settings_controller.rb @@ -20,7 +20,7 @@ class CourseSettingsController < ApplicationController # rubocop:disable Metrics/AbcSize def update @side_nav = 'course_settings' - @course_settings = @course.course_settings || @course.build_course_settings + @course_settings = @course.course_settings if params[:reset_email_template].present? reset_email_templates @@ -59,24 +59,26 @@ def reset_email_templates end def course_settings_params - params.require(:course_settings).permit( - :enable_extensions, - :auto_approve_days, - :auto_approve_extended_request_days, - :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, - :pending_notification_frequency, - :pending_notification_email + params.expect( + course_settings: [ + :enable_extensions, + :auto_approve_days, + :auto_approve_extended_request_days, + :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, + :pending_notification_frequency, + :pending_notification_email + ] ) end diff --git a/app/controllers/courses_controller.rb b/app/controllers/courses_controller.rb index 498ce5ba..57cfbdcc 100644 --- a/app/controllers/courses_controller.rb +++ b/app/controllers/courses_controller.rb @@ -5,15 +5,12 @@ class CoursesController < ApplicationController before_action :determine_user_role def index - teacher_courses = UserToCourse.includes(:course).where(user: @user, role: UserToCourse.staff_roles) + teacher_courses = Enrollment.includes(:course).where(user: @user, role: Enrollment.staff_roles) @teacher_courses_by_semester = group_by_semester(teacher_courses) # Only show courses to students if extensions are enabled at the course level - student_courses = UserToCourse.includes(course: :course_settings).where(user: @user, role: 'student') - visible_student_courses = student_courses.select do |utc| - course_settings = utc.course.course_settings - course_settings.nil? || course_settings.enable_extensions - end + student_courses = Enrollment.includes(course: :course_settings).where(user: @user, role: 'student') + visible_student_courses = student_courses.select { |enrollment| enrollment.course.requests_enabled? } @student_courses_by_semester = group_by_semester(visible_student_courses) # Keep flat lists for conditional checks in the view @@ -29,8 +26,7 @@ def show @course.regenerate_readonly_api_token_if_blank if @role == 'student' - course_settings = @course.course_settings - return redirect_to courses_path, alert: 'Extensions are not enabled for this course.' unless course_settings&.enable_extensions + return redirect_to courses_path, alert: 'Extensions are not enabled for this course.' unless @course.requests_enabled? @assignments = @course.enabled_assignments else @@ -51,10 +47,10 @@ def new # TODO: Add spec for when a course is created, but the user is not enrolled in it. # TODO: Why do some courses have empty enrollments? existing_canvas_ids = @user.courses.pluck(:canvas_id) - @courses_teacher = filter_courses(@courses, UserToCourse.staff_roles, existing_canvas_ids) + @courses_teacher = filter_courses(@courses, Enrollment.staff_roles, existing_canvas_ids) # Track if any teacher courses, so we still show the semester filter even if the selected semester filters out all courses. @has_any_teacher_courses = @courses_teacher.any? - @courses_student = filter_courses(@courses, [ UserToCourse::STUDENT_ROLE ], existing_canvas_ids) + @courses_student = filter_courses(@courses, [ Enrollment::STUDENT_ROLE ], existing_canvas_ids) if @selected_semester.present? @courses_teacher = filter_by_semester(@courses_teacher, @selected_semester) @@ -69,7 +65,7 @@ def edit def create token = @user.lms_credentials.first.token - filter_courses(Course.fetch_courses(token), UserToCourse.staff_roles) + filter_courses(Course.fetch_courses(token), Enrollment.staff_roles) .select { |c| params[:courses]&.include?(c['id'].to_s) } .each { |course_api| Course.create_or_update_from_canvas(course_api, token, @user) } redirect_to courses_path, notice: 'Selected courses and their assignments have been imported successfully.' @@ -94,24 +90,24 @@ def enrollments @side_nav = 'enrollments' return redirect_to courses_path, alert: 'You do not have access to this page.' unless @role == 'instructor' - @enrollments = @course.user_to_courses.includes(:user) + @enrollments = @course.enrollments.includes(:user) @is_course_admin = @course.course_admin?(@user) @enrollments_last_synced_at = enrollments_last_synced_at end def delete return redirect_to courses_path, alert: 'You do not have access to this page.' unless @role == 'instructor' - return redirect_to courses_path, alert: 'Extensions are enabled for this course.' if @course.course_settings&.enable_extensions + return redirect_to courses_path, alert: 'Extensions are enabled for this course.' if @course.requests_enabled? - assignments = Assignment.where(course_to_lms_id: CourseToLms.where(course_id: @course.id).select(:id)) + assignments = @course.assignments Extension.where(assignment_id: assignments.select(:id)).destroy_all assignments.destroy_all CourseToLms.where(course_id: @course.id).destroy_all - UserToCourse.where(course_id: @course.id).destroy_all + Enrollment.where(course_id: @course.id).destroy_all Request.where(course_id: @course.id).destroy_all CourseSettings.where(course_id: @course.id).destroy_all FormSetting.where(course_id: @course.id).destroy_all - Course.where.missing(:user_to_courses).destroy_all + Course.where.missing(:enrollments).destroy_all redirect_to courses_path, notice: 'Course deleted successfully.' end @@ -148,10 +144,10 @@ def determine_user_role @is_course_admin = @course&.course_admin?(@user) || false end - # Groups UserToCourse records by their course's semester, sorted most-recent-first. - # Returns an array of [semester_name, [user_to_courses]] pairs. - def group_by_semester(user_to_courses) - grouped = user_to_courses.group_by { |utc| utc.course.semester } + # Groups Enrollment records by their course's semester, sorted most-recent-first. + # Returns an array of [semester_name, [enrollments]] pairs. + def group_by_semester(enrollments) + grouped = enrollments.group_by { |enrollment| enrollment.course.semester } sorted_semesters = Course.sort_semesters(grouped.keys) sorted_semesters.map { |semester| [ semester, grouped[semester] ] } end @@ -172,7 +168,7 @@ def filter_courses(courses, roles, exclude_ids = []) return [] if courses.empty? courses.select do |course| - course['enrollments'].any? { |enrollment| roles.include?(UserToCourse.role_from_canvas_enrollment(enrollment)) } + course['enrollments'].any? { |enrollment| roles.include?(Enrollment.role_from_canvas_enrollment(enrollment)) } end end diff --git a/app/controllers/user_to_courses_controller.rb b/app/controllers/enrollments_controller.rb similarity index 90% rename from app/controllers/user_to_courses_controller.rb rename to app/controllers/enrollments_controller.rb index b5f22aad..cd6f2eef 100644 --- a/app/controllers/user_to_courses_controller.rb +++ b/app/controllers/enrollments_controller.rb @@ -1,4 +1,4 @@ -class UserToCoursesController < ApplicationController +class EnrollmentsController < ApplicationController # `authenticated!` from ApplicationController runs before this filter, so # `current_user` is populated by the time we get here. before_action :set_course @@ -20,7 +20,7 @@ def toggle_allow_extended_requests private def set_enrollment - @enrollment = UserToCourse.find(params[:id]) + @enrollment = @course.enrollments.find(params[:id]) end # ApplicationController#ensure_instructor_role would redirect with a flash, diff --git a/app/controllers/form_settings_controller.rb b/app/controllers/form_settings_controller.rb index 8b14e53f..c0c3389c 100644 --- a/app/controllers/form_settings_controller.rb +++ b/app/controllers/form_settings_controller.rb @@ -37,10 +37,12 @@ def update private def form_setting_params - params.require(:form_setting).permit( - :reason_desc, :documentation_desc, :documentation_disp, - :custom_q1, :custom_q1_desc, :custom_q1_disp, - :custom_q2, :custom_q2_desc, :custom_q2_disp + params.expect( + form_setting: [ + :reason_desc, :documentation_desc, :documentation_disp, + :custom_q1, :custom_q1_desc, :custom_q1_disp, + :custom_q2, :custom_q2_desc, :custom_q2_disp + ] ) end diff --git a/app/controllers/requests/exports_controller.rb b/app/controllers/requests/exports_controller.rb new file mode 100644 index 00000000..114bce7b --- /dev/null +++ b/app/controllers/requests/exports_controller.rb @@ -0,0 +1,19 @@ +module Requests + # Public, token-authenticated CSV export of a course's requests. It has no + # user session, so it lives outside RequestsController and its user/role/ + # membership before_actions, authenticating solely via the course's + # read-only API token. + class ExportsController < ApplicationController + def show + course = Course.find_by(id: params[:course_id]) + token = params[:readonly_api_token] + + return render plain: 'Invalid or missing API token', status: :unauthorized unless course && ActiveSupport::SecurityUtils.secure_compare(course.readonly_api_token, token.to_s) + + requests = course.requests.includes(:assignment, :user) + requests = requests.where(status: params[:status]) if params[:status].present? + + send_data Request.to_csv(requests), filename: 'requests.csv', type: 'text/csv' + end + end +end diff --git a/app/controllers/requests_controller.rb b/app/controllers/requests_controller.rb index ab5ff3ab..b464fc7b 100644 --- a/app/controllers/requests_controller.rb +++ b/app/controllers/requests_controller.rb @@ -1,26 +1,23 @@ -require 'csv' - # We really should get a handle on this. # rubocop:disable Metrics/ClassLength class RequestsController < ApplicationController - # Consider moving export, approve/reject to a separate controller? - before_action :authenticate_user, except: [ :export ] - before_action :set_course_role_from_settings, except: [ :export ] - before_action :authenticate_course, except: [ :export ] - before_action :set_pending_request_count, except: [ :export ] - before_action :check_extensions_enabled_for_students, except: [ :export ] + before_action :authenticate_user + before_action :set_course + before_action :set_form_settings + before_action :require_course_membership + before_action :set_pending_request_count + before_action :check_extensions_enabled_for_students before_action :set_request, only: %i[show edit update cancel approve reject] before_action :ensure_request_is_pending, only: %i[update approve reject] - before_action :check_instructor_permission, only: %i[approve reject mass_approve mass_reject] + before_action :ensure_instructor_role, only: %i[create_for_student approve reject mass_approve mass_reject] def index @side_nav = 'requests' - if @role == 'student' - @requests = @course.requests.for_user(@user) - elsif params[:show_all] == 'true' - @requests = @course.requests.includes(:assignment) + if @course.course_staff?(@user) + scope = @course.requests.includes(:assignment) + @requests = params[:show_all] == 'true' ? scope : scope.pending else - @requests = @course.requests.includes(:assignment).where(status: 'pending') + @requests = @course.requests.for_user(@user) end # Pass the search query to the view @@ -37,33 +34,12 @@ def show def new @side_nav = 'form' - # course_to_lms = @course.course_to_lms(1) - course_to_lmss = @course.all_linked_lmss.pluck(:id) - return redirect_to courses_path, alert: 'No Canvas LMS data found for this course.' unless course_to_lmss.any? - - if @role == 'instructor' - prepare_instructor_new_request(course_to_lmss) - render :new_for_student and return - elsif @role == 'student' - redirected = prepare_student_new_request(course_to_lmss) - return if redirected - - render :new and return - else - redirect_to course_path(@course.id), alert: 'You do not have access to this page.' - end - end - - def new_for_student - @side_nav = 'form' - return redirect_to course_requests_path(@course), alert: 'You do not have permission to access this page.' unless @role == 'instructor' + return redirect_to courses_path, alert: 'No Canvas LMS data found for this course.' unless @course.has_canvas_linked? - course_to_lmss = @course.all_linked_lmss.pluck(:id) - return redirect_to courses_path, alert: 'No Canvas LMS data found for this course.' unless course_to_lmss.any? + return new_for_student if @course.course_staff?(@user) - @assignments = Assignment.enabled_for_course(course_to_lmss).order(:name) - @students = User.joins(:user_to_courses).where(user_to_courses: { course_id: @course.id, role: 'student' }).order(:name) - @request = @course.requests.new + redirected = prepare_student_new_request + render :new unless redirected end def edit @@ -90,8 +66,6 @@ def create end def create_for_student - return redirect_to course_requests_path(@course), alert: 'You do not have permission to perform this action.' unless @role == 'instructor' - student = User.find_by(id: params[:request][:user_id]) return redirect_to new_course_request_path(@course), alert: 'Student is not enrolled in this course.' unless student_enrolled_in_course?(student) @@ -110,7 +84,7 @@ def create_for_student def update Request.merge_date_and_time!(params[:request]) - @request.assign_attributes(update_request_params) + @request.assign_attributes(request_params) return unless ensure_assignment_in_course if @request.save @@ -131,13 +105,11 @@ def cancel end def approve - @assignment = Assignment.find_by(id: @request.assignment_id) - lms_facade = @assignment.lms_facade - if @request.approve(lms_facade.from_user(@user), @user) + if @request.approve_by(@user) notice = 'Request approved and extension created successfully in Canvas.' respond_to do |format| format.html { redirect_to course_requests_path(@course), notice: notice } - format.json { render json: { success: true, message: notice, new_status: 'approved', pending_count: @course.requests.where(status: 'pending').count } } + format.json { render json: { success: true, message: notice, new_status: 'approved', pending_count: @course.requests.pending.count } } end else alert = "Failed to approve the request. #{@request.errors.full_messages.join(', ')}" @@ -153,7 +125,7 @@ def reject notice = 'Request denied successfully.' respond_to do |format| format.html { redirect_to course_requests_path(@course), notice: notice } - format.json { render json: { success: true, message: notice, new_status: 'denied', pending_count: @course.requests.where(status: 'pending').count } } + format.json { render json: { success: true, message: notice, new_status: 'denied', pending_count: @course.requests.pending.count } } end else alert = 'Failed to deny the request.' @@ -172,19 +144,6 @@ def mass_reject process_mass_action(:reject) end - def export - course = Course.find_by(id: params[:course_id]) - token = params[:readonly_api_token] - - return render plain: 'Invalid or missing API token', status: :unauthorized unless course && ActiveSupport::SecurityUtils.secure_compare(course.readonly_api_token, token.to_s) - - requests = course.requests.includes(:assignment, :user) - requests = requests.where(status: params[:status]) if params[:status].present? - - csv_data = Request.to_csv(requests) - send_data csv_data, filename: 'requests.csv', type: 'text/csv' - end - private # Loads the request for member actions. Scoped so students can only reach @@ -202,39 +161,38 @@ def requests_visible_to_user @course.course_staff?(@user) ? @course.requests : @course.requests.for_user(@user) end - def check_instructor_permission - result = RequestService.check_instructor_permission(@role, course_path(@course)) - redirect_to result[:redirect_to], alert: result[:alert] if result != true + # Every request action operates inside a course the user belongs to, so a + # user with no role in the course is bounced before reaching any action. + def require_course_membership + return if enrolled_in_course? + + redirect_to course_path(@course), alert: 'You do not have access to this page.' + end + + # A user is a member of the course if they are staff or an enrolled student. + def enrolled_in_course? + @course.course_staff?(@user) || @course.course_student?(@user) end def handle_request_error flash.now[:alert] = 'There was a problem submitting your request.' - @assignments = Assignment.enabled_for_course(@course.all_linked_lmss.pluck(:id)).order(:name) + @assignments = @course.enabled_assignments.order(:name) @selected_assignment = Assignment.find_by(id: params[:assignment_id]) if params[:assignment_id] render :new end - def set_course_role_from_settings - result = RequestService.set_course_role_from_settings(params[:course_id], @user) - - if result[:redirect_to] - redirect_to result[:redirect_to], alert: result[:alert] - return - end - - @course = result[:course] - @role = result[:role] - @form_settings = result[:form_settings] + # @course and @role are set by ApplicationController#set_course. The request + # form views additionally need the course's form configuration. + def set_form_settings + @form_settings = @course.form_setting end + # The assignment is chosen at creation and is not editable afterwards, so the + # update action is not permitted to write assignment_id; other actions may. def request_params - params.require(:request).permit(:assignment_id, :reason, :documentation, :custom_q1, :custom_q2, :requested_due_date) - end - - # The assignment is chosen at creation and is not editable afterwards, so it - # is dropped from the params an update is allowed to write. - def update_request_params - request_params.except(:assignment_id) + permitted = [ :reason, :documentation, :custom_q1, :custom_q2, :requested_due_date ] + permitted.unshift(:assignment_id) unless action_name == 'update' + params.expect(request: permitted) end # Every request must reference an assignment in this course. A new request @@ -265,41 +223,40 @@ def student_enrolled_in_course?(student) @course.course_student?(student) end - def authenticate_user - result = RequestService.authenticate_user(session[:user_id]) - - if result[:redirect_to] - redirect_to result[:redirect_to], alert: result[:alert] - return - end + # Runs after set_request, so @request is already loaded and scoped; a missing + # request has already been redirected as "not found" by set_request. + def ensure_request_is_pending + return if @request.pending? - @user = result[:user] + redirect_to course_path(@course), alert: 'This action can only be performed on pending requests.' end - def authenticate_course - result = RequestService.authenticate_course(@course, courses_path) - redirect_to result[:redirect_to], alert: result[:alert] if result != true - end + # Students may only reach requests while the course has extensions enabled. + # Staff are always allowed through so they can manage existing requests. + def check_extensions_enabled_for_students + return unless @course.course_student?(@user) + return if @course.requests_enabled? - # Runs after set_request, so @request is already loaded and scoped. - def ensure_request_is_pending - result = RequestService.ensure_request_is_pending(@request, course_path(@course)) - redirect_to result[:redirect_to], alert: result[:alert] if result != true + redirect_to courses_path, alert: 'Extensions are not enabled for this course.' end - def check_extensions_enabled_for_students - result = RequestService.check_extensions_enabled_for_students(@role, @course, courses_path) - redirect_to result[:redirect_to], alert: result[:alert] if result != true + # Prepares and renders the form staff use to submit a request on behalf of a + # student. Not a routed action -- it is reached only from #new once the + # caller has been confirmed as course staff and the course has a linked LMS. + def new_for_student + @side_nav = 'form' + prepare_instructor_new_request + render :new_for_student end - def prepare_instructor_new_request(course_to_lms_ids) - @students = User.joins(:user_to_courses).where(user_to_courses: { course_id: @course.id, role: 'student' }).order(:name) + def prepare_instructor_new_request + @students = User.joins(:enrollments).where(enrollments: { course_id: @course.id, role: 'student' }).order(:name) @request = @course.requests.new - @assignments = Assignment.enabled_for_course(course_to_lms_ids).order(:name) + @assignments = @course.enabled_assignments.order(:name) end - def prepare_student_new_request(course_to_lms_ids) - all_assignments = Assignment.enabled_for_course(course_to_lms_ids).order(:name) + def prepare_student_new_request + all_assignments = @course.enabled_assignments.order(:name) @assignments = all_assignments.reject { |assignment| assignment.has_pending_request_for_user?(@user, @course) } @has_pending = all_assignments.size != @assignments.size @selected_assignment = Assignment.find_by(id: params[:assignment_id]) if params[:assignment_id] @@ -323,7 +280,7 @@ def handle_successful_student_request(student) end def render_new_for_student_error - prepare_instructor_new_request(@course.all_linked_lmss.pluck(:id)) + prepare_instructor_new_request flash.now[:alert] = 'There was a problem submitting the request.' render :new_for_student end @@ -341,7 +298,7 @@ def process_mass_action(action) ) end - requests = @course.requests.where(id: request_ids, status: 'pending').includes(:assignment) + requests = @course.requests.where(id: request_ids).pending.includes(:assignment) if requests.empty? return render_mass_action_response( @@ -384,7 +341,7 @@ def process_mass_action(action) end def render_mass_action_response(success:, message:, processed_ids:, failed_ids:, new_status:, status:) - pending_count = @course.requests.where(status: 'pending').count + pending_count = @course.requests.pending.count respond_to do |format| format.html do if success @@ -407,12 +364,11 @@ def render_mass_action_response(success:, message:, processed_ids:, failed_ids:, end def approve_request_for_mass_action(request) - lms_facade = request.assignment&.lms_facade - return false unless lms_facade - - request.approve(lms_facade.from_user(@user), @user) + request.approve_by(@user) rescue StandardError => e Rails.logger.error("Mass approve failed for request #{request.id}: #{e.message}") + Rails.error.report(e, handled: true, + context: { component: 'mass_approve', request_id: request.id, actor_id: @user&.id }) false end diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index 4a1d0b97..ccb25260 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -84,6 +84,7 @@ def omniauth_callback redirect_to courses_path, notice: "Logged in! Welcome, #{user_data['name']}!" rescue StandardError => e Rails.logger.error("OmniAuth callback error: #{e.message}") + Rails.error.report(e, handled: true, context: { component: 'omniauth_callback' }) redirect_to root_path, alert: 'Authentication failed. Invalid credentials.' end @@ -103,8 +104,8 @@ def ensure_developer_test_enrollments(user) # Ensure enrollment in the test course (as student so they can request extensions) if test_course - UserToCourse.find_or_create_by!(user_id: user.id, course_id: test_course.id) do |utc| - utc.role = 'student' + Enrollment.find_or_create_by!(user_id: user.id, course_id: test_course.id) do |enrollment| + enrollment.role = 'student' end end end diff --git a/app/facades/canvas_facade.rb b/app/facades/canvas_facade.rb index 114ff603..de21c79c 100644 --- a/app/facades/canvas_facade.rb +++ b/app/facades/canvas_facade.rb @@ -10,7 +10,7 @@ class CanvasAPIError < LmsFacade::LmsAPIError; end CANVAS_URL = ENV.fetch('CANVAS_URL', nil) CANVAS_CUSTOM_COURSE_ROLES = { - UserToCourse::LEAD_TA_ROLE => 'Lead TA' + Enrollment::LEAD_TA_ROLE => 'Lead TA' }.freeze # Canvas instances can scope the flextensions developer key. @@ -30,6 +30,11 @@ class CanvasAPIError < LmsFacade::LmsAPIError; end # Whenever a new scope is added, it must be added to the Canvas developer key **FIRST**, # especially in the production environment. You will likely want to use feature flags # and coordination with Berkeley to ensure that the scopes are added to the developer key. + # NOTE: Changing the key's scopes invalidates EVERY token already derived from the key. + # Run `bin/rails lms_credentials:purge` afterwards to drop the dead stored credentials; + # users re-authenticate automatically on their next visit. (Any dead credential that is + # not purged is also removed automatically the first time its refresh fails with + # invalid_grant -- see LmsCredential#refresh!.) # NOTE: This is read into the OmniAuth initializer in `config/initializers/omniauth.rb`. # If you change this list, you will need to restart the Rails server. CANVAS_API_SCOPES = [ @@ -200,7 +205,7 @@ def get_instructor_courses end def role_query_param(role) - normalized_role = UserToCourse.normalize_role(role) + normalized_role = Enrollment.normalize_role(role) canvas_course_role = CANVAS_CUSTOM_COURSE_ROLES[normalized_role] if canvas_course_role diff --git a/app/facades/gradescope_facade.rb b/app/facades/gradescope_facade.rb index 4a342dfb..6f5f987c 100644 --- a/app/facades/gradescope_facade.rb +++ b/app/facades/gradescope_facade.rb @@ -44,6 +44,8 @@ def get_all_assignments(course_id) raise e rescue => e Rails.logger.error "Failed to fetch Gradescope assignments: #{e.message}" + Rails.error.report(e, handled: true, + context: { component: 'gradescope', operation: 'get_all_assignments', course_id: course_id }) [] end end @@ -70,6 +72,8 @@ def get_assignment_overrides(course_id, assignment_id) overrides_data.map { |data| Lmss::Gradescope::Override.new(data) } rescue => e Rails.logger.error "Failed to fetch assignment extensions: #{e.message}" + Rails.error.report(e, handled: true, + context: { component: 'gradescope', operation: 'get_assignment_overrides', course_id: course_id, assignment_id: assignment_id }) [] end end diff --git a/app/jobs/sync_users_from_canvas_job.rb b/app/jobs/sync_users_from_canvas_job.rb index e8b8a703..8b3f2cac 100644 --- a/app/jobs/sync_users_from_canvas_job.rb +++ b/app/jobs/sync_users_from_canvas_job.rb @@ -42,16 +42,16 @@ def sync_users_for_role(course, user, role) users_updated = 0 # Handle removals - only for the current role - existing_role_enrollments = UserToCourse.joins(:user) + existing_role_enrollments = Enrollment.joins(:user) .where(course_id: course.id, role: role) - .select('user_to_courses.*, users.canvas_uid') + .select('enrollments.*, users.canvas_uid') - enrollments_to_remove = existing_role_enrollments.reject do |utc| - current_canvas_user_ids.include?(utc.canvas_uid) + enrollments_to_remove = existing_role_enrollments.reject do |enrollment| + current_canvas_user_ids.include?(enrollment.canvas_uid) end if enrollments_to_remove.any? - UserToCourse.where(id: enrollments_to_remove.map(&:id)).destroy_all + Enrollment.where(id: enrollments_to_remove.map(&:id)).destroy_all users_removed = enrollments_to_remove.size end @@ -91,7 +91,7 @@ def sync_users_for_role(course, user, role) # Get existing enrollments to avoid duplicates existing_user_ids = users_by_canvas_uid.values.map(&:id) - existing_enrollments = UserToCourse.where( + existing_enrollments = Enrollment.where( user_id: existing_user_ids, course_id: course.id, role: role @@ -115,7 +115,7 @@ def sync_users_for_role(course, user, role) if enrollments_to_create.any? begin - UserToCourse.insert_all(enrollments_to_create) + Enrollment.insert_all(enrollments_to_create) rescue => e Rails.logger.debug { "DEBUG: Insert failed: #{e.message}" } Rails.logger.debug { "DEBUG: #{e.backtrace.first(3)}" } diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 00000000..7c8a5a73 --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# Base class for all application mailers. +class ApplicationMailer < ActionMailer::Base +end diff --git a/app/mailers/templated_mailer.rb b/app/mailers/templated_mailer.rb new file mode 100644 index 00000000..763ae5f5 --- /dev/null +++ b/app/mailers/templated_mailer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# Delivers a pre-rendered HTML email. The subject and body are already +# interpolated by EmailService, so the action just wraps them in a message +# rather than rendering a view template. +class TemplatedMailer < ApplicationMailer + def templated_email(to:, from:, reply_to:, subject:, body:) + mail( + to: to, + from: from, + reply_to: reply_to, + subject: subject, + body: body, + content_type: 'text/html' + ) + end +end diff --git a/app/models/assignment.rb b/app/models/assignment.rb index f10dc239..fd4ae5a1 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -10,17 +10,28 @@ # name :string # created_at :datetime not null # updated_at :datetime not null +# course_id :bigint not null # course_to_lms_id :bigint not null # external_assignment_id :string # +# Indexes +# +# index_assignments_on_course_id (course_id) +# # Foreign Keys # +# fk_rails_... (course_id => courses.id) # fk_rails_... (course_to_lms_id => course_to_lmss.id) # class Assignment < ApplicationRecord + belongs_to :course belongs_to :course_to_lms has_many :requests, dependent: :destroy + # course_id is denormalized from course_to_lms so course-scoped queries + # don't need a join; default it so callers only have to set course_to_lms. + before_validation :set_course_from_course_to_lms + validates :name, presence: true validates :external_assignment_id, presence: true @@ -28,14 +39,15 @@ class Assignment < ApplicationRecord delegate :lms_id, to: :course_to_lms - # Returns enabled assignments for a specific course - scope :enabled_for_course, ->(course_to_lms_id) { where(course_to_lms_id: course_to_lms_id, enabled: true) } - # Check if there's a pending request for this assignment by a specific user def has_pending_request_for_user?(user, course) requests.exists?(user: user, course: course, status: 'pending') end + def set_course_from_course_to_lms + self.course ||= course_to_lms&.course + end + def enabled_requires_date_present errors.add(:due_date, 'must be present if assignment is enabled') if enabled && due_date.blank? end diff --git a/app/models/course.rb b/app/models/course.rb index 3cff1b6f..3bdc0ca9 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -20,21 +20,23 @@ class Course < ApplicationRecord has_secure_token :readonly_api_token after_create :regenerate_readonly_api_token_if_blank - # TODO: after_initialize :build_course_settings_if_necessary + # Every course has exactly one course_settings record (unique index on course_id). + after_create :create_default_course_settings # Associations + # Declared before course_to_lmss so a destroy removes assignments first, + # satisfying their FK on course_to_lms_id. + has_many :assignments, dependent: :destroy has_many :course_to_lmss, dependent: :destroy has_many :lmss, through: :course_to_lmss - has_many :user_to_courses, dependent: :destroy + has_many :enrollments, dependent: :destroy has_one :form_setting, dependent: :destroy has_one :course_settings, dependent: :destroy has_many :requests, dependent: :destroy - has_many :users, through: :user_to_courses + has_many :users, through: :enrollments - # Validations validates :course_name, presence: true - # validate :ensure_course_settings # Scopes scope :by_semester, ->(semester) { where(semester: semester) } @@ -109,8 +111,9 @@ def has_canvas_linked? course_to_lms(1).present? end - def assignments - Assignment.where(course_to_lms: course_to_lmss).order(:name) + # Whether students can see this course and submit extension requests. + def requests_enabled? + course_settings.enable_extensions? end def enabled_assignments @@ -120,23 +123,23 @@ def enabled_assignments # TODO: Replace this with staff_role?(user) or student_role?(user) # Or is user.staff_role?(course) or user.student_role?(course) better? def user_role(user) - roles = UserToCourse.where(user_id: user.id, course_id: id).pluck(:role) - return 'instructor' if roles.intersect?(UserToCourse.staff_roles) - return 'student' if roles.include?(UserToCourse::STUDENT_ROLE) + roles = Enrollment.where(user_id: user.id, course_id: id).pluck(:role) + return 'instructor' if roles.intersect?(Enrollment.staff_roles) + return 'student' if roles.include?(Enrollment::STUDENT_ROLE) nil end def course_admin?(user) - user_to_courses.where(user_id: user.id).any?(&:course_admin?) + enrollments.where(user_id: user.id).any?(&:course_admin?) end def course_staff?(user) - user_to_courses.where(user_id: user.id).any?(&:staff?) + enrollments.where(user_id: user.id).any?(&:staff?) end def course_student?(user) - user_to_courses.where(user_id: user.id).any?(&:student?) + enrollments.where(user_id: user.id).any?(&:student?) end # TODO: This doesn't make sense actually. @@ -147,42 +150,51 @@ def course_student?(user) # end def canvas_id - CourseToLms.find_by(course_id: id, lms_id: CANVAS_LMS_ID)&.external_course_id + external_course_id_for(CANVAS_LMS_ID) end def gradescope_id - CourseToLms.find_by(course_id: id, lms_id: GRADESCOPE_LMS_ID)&.external_course_id + external_course_id_for(GRADESCOPE_LMS_ID) end - # TODO: Add specs for these 4 simple methods - def assignments - Assignment.joins(:course_to_lms).where(course_to_lms: { course_id: id }) + # Returns the external course id for the given LMS. A course should have at + # most one link per LMS, but when several exist we deterministically prefer a + # link that actually carries an external id (ordered by id) so callers never + # get an arbitrary nil back. + def external_course_id_for(lms_id) + links = CourseToLms.where(course_id: id, lms_id: lms_id).order(:id) + (links.where.not(external_course_id: nil).first || links.first)&.external_course_id end + # TODO: Add specs for these 3 simple methods def students - user_to_courses.where(role: UserToCourse::STUDENT_ROLE).map(&:user) + enrollments.where(role: Enrollment::STUDENT_ROLE).map(&:user) end def instructors - user_to_courses.where(role: UserToCourse::TEACHER_ROLE).map(&:user) + enrollments.where(role: Enrollment::TEACHER_ROLE).map(&:user) end def staff_users - user_to_courses.where(role: UserToCourse.staff_roles).map(&:user) + enrollments.where(role: Enrollment.staff_roles).map(&:user) end - def destroy_associations - assignments.destroy_all - course_to_lmss.destroy_all - user_to_courses.destroy_all - form_setting.destroy if form_setting - course_settings.destroy if course_settings + # Staff users with Canvas credentials on file, most recently refreshed + # first -- Canvas revokes refresh tokens that go unused for months, so the + # staff member who logged in most recently is the most likely to still + # work. Credentials on file can still fail to refresh or belong to someone + # who has since left the Canvas course, so callers should be prepared to + # fall back to the next user in this list. Staff synced from the Canvas + # roster who never logged into Flextensions have no credentials and are + # excluded. + def staff_users_for_auto_approval + staff_users.select { |user| user.canvas_credentials.present? } + .sort_by { |user| user.canvas_credentials.updated_at } + .reverse end - # Find the first staff user who has a Canvas Token that can be used - # to post requests to Canvas. def staff_user_for_auto_approval - user_to_courses.where(role: UserToCourse.staff_roles).first&.user + staff_users_for_auto_approval.first end # Fetch courses from Canvas API @@ -216,37 +228,6 @@ def self.create_or_update_from_canvas(course_data, token, user) form_setting.save! end - # Create a 1-to-1 course_settings record if it doesn't exist - unless course.course_settings - course_settings = course.build_course_settings( - enable_extensions: false, - auto_approve_days: 0, - auto_approve_extended_request_days: 0, - max_auto_approve: 0, - enable_gradescope: false, - gradescope_course_url: nil, - enable_emails: false, - reply_email: nil, - email_subject: 'Extension Request Status: {{status}} - {{course_code}}', - email_template: <<~TEMPLATE - Dear {{student_name}}, - - Your extension request for {{assignment_name}} in {{course_name}} ({{course_code}}) has been {{status}}. - - Extension Details: - - Original Due Date: {{original_due_date}} - - New Due Date: {{new_due_date}} - - Extension Days: {{extension_days}} - - If you have any questions, please contact the course staff. - - Best regards, - {{course_name}} Staff - TEMPLATE - ) - course_settings.save! - end - # TODO: Consider disabling these if performance becomes an issue course.sync_assignments(user) course.sync_all_enrollments_from_canvas(user.id) @@ -293,17 +274,23 @@ def sync_assignments(sync_user) end end - # Fetch users for a course and create/find their User and UserToCourse records + # Fetch users for a course and create/find their User and Enrollment records # TODO: This may need to become a background job def sync_users_from_canvas(user, roles = [ 'student' ]) SyncUsersFromCanvasJob.perform_now(id, user, roles) end def sync_all_enrollments_from_canvas(user) - sync_users_from_canvas(user, UserToCourse.roles) + sync_users_from_canvas(user, Enrollment.roles) end def regenerate_readonly_api_token_if_blank regenerate_readonly_api_token if readonly_api_token.blank? end + + # Course settings are created with the column defaults; the guard only + # matters when settings were built in memory before the course was saved. + def create_default_course_settings + create_course_settings! unless course_settings + end end diff --git a/app/models/course_settings.rb b/app/models/course_settings.rb index c04749bd..f96afcb0 100644 --- a/app/models/course_settings.rb +++ b/app/models/course_settings.rb @@ -17,6 +17,8 @@ # gradescope_course_url :string # max_auto_approve :integer default(0) # min_hours_before_deadline :integer default(0), not null +# pending_notification_email :string +# pending_notification_frequency :string # reply_email :string # slack_webhook_url :string # created_at :datetime not null @@ -25,7 +27,7 @@ # # Indexes # -# index_course_settings_on_course_id (course_id) +# index_course_settings_on_course_id (course_id) UNIQUE # # Foreign Keys # @@ -55,6 +57,10 @@ class CourseSettings < ApplicationRecord belongs_to :course + # Courses and settings are 1:1; the course_id unique index enforces this at + # the database level. + validates :course_id, uniqueness: true + # Empty submissions become "" — coerce to nil so # `allow_nil` behaves as expected and unset rows compare equal. normalizes :pending_notification_frequency, :pending_notification_email, with: ->(v) { v.presence } @@ -92,12 +98,12 @@ def ensure_system_user_for_auto_approval # TODO: if disabled should unsync Gradescope assignments def create_or_update_gradescope_link - if course.course_settings.enable_gradescope - gradescope_course_id = extract_gradescope_course_id(course.course_settings.gradescope_course_url) - CourseToLms.find_or_initialize_by(course_id: course.id, lms_id: GRADESCOPE_LMS_ID).tap do |course_to_lms| - course_to_lms.external_course_id = gradescope_course_id - course_to_lms.save! - end + return unless enable_gradescope + + gradescope_course_id = extract_gradescope_course_id(gradescope_course_url) + CourseToLms.find_or_initialize_by(course_id: course.id, lms_id: GRADESCOPE_LMS_ID).tap do |course_to_lms| + course_to_lms.external_course_id = gradescope_course_id + course_to_lms.save! end end diff --git a/app/models/course_to_lms.rb b/app/models/course_to_lms.rb index 33ccd694..a4d7973f 100644 --- a/app/models/course_to_lms.rb +++ b/app/models/course_to_lms.rb @@ -31,6 +31,8 @@ def get_all_canvas_assignments(user) CanvasFacade.from_user(user).get_all_assignments(external_course_id) rescue StandardError => e Rails.logger.error "Failed to fetch Canvas assignments: #{e.message}" + Rails.error.report(e, handled: true, + context: { component: 'canvas_assignments', course_to_lms_id: id, external_course_id: external_course_id }) [] end @@ -40,6 +42,8 @@ def fetch_gradescope_assignments GradescopeFacade.from_user.get_all_assignments(external_course_id) rescue StandardError => e Rails.logger.error "Failed to fetch Gradescope assignments: #{e.message}" + Rails.error.report(e, handled: true, + context: { component: 'gradescope_assignments', course_to_lms_id: id, external_course_id: external_course_id }) [] end end diff --git a/app/models/user_to_course.rb b/app/models/enrollment.rb similarity index 83% rename from app/models/user_to_course.rb rename to app/models/enrollment.rb index 33b983a5..605b43e3 100644 --- a/app/models/user_to_course.rb +++ b/app/models/enrollment.rb @@ -1,6 +1,6 @@ # == Schema Information # -# Table name: user_to_courses +# Table name: enrollments # # id :bigint not null, primary key # allow_extended_requests :boolean default(FALSE), not null @@ -13,16 +13,15 @@ # # Indexes # -# index_user_to_courses_on_course_id (course_id) -# index_user_to_courses_on_user_id (user_id) +# index_enrollments_on_course_id (course_id) +# index_enrollments_on_user_id (user_id) # # Foreign Keys # # fk_rails_... (course_id => courses.id) # fk_rails_... (user_id => users.id) # -# TODO: In the future we should name this CourseEnrollment -class UserToCourse < ApplicationRecord +class Enrollment < ApplicationRecord STUDENT_ROLE = 'student'.freeze TEACHER_ROLE = 'teacher'.freeze TA_ROLE = 'ta'.freeze @@ -45,11 +44,11 @@ class UserToCourse < ApplicationRecord def staff? - UserToCourse.staff_roles.include?(role) + Enrollment.staff_roles.include?(role) end def course_admin? - UserToCourse.course_admin_roles.include?(role) + Enrollment.course_admin_roles.include?(role) end def student? @@ -57,11 +56,11 @@ def student? end def display_role - UserToCourse.display_role(role) + Enrollment.display_role(role) end def self.roles - [ STUDENT_ROLE ] + UserToCourse.staff_roles + [ STUDENT_ROLE ] + Enrollment.staff_roles end def self.staff_roles diff --git a/app/models/lms_credential.rb b/app/models/lms_credential.rb index a75fbcfb..6b8b8200 100644 --- a/app/models/lms_credential.rb +++ b/app/models/lms_credential.rb @@ -33,4 +33,53 @@ class LmsCredential < ApplicationRecord # Encryption for tokens encrypts :token, :refresh_token + + # Whether the access token expires within the given buffer (so callers can + # refresh it before starting a batch of API calls). + def expires_soon?(buffer = 15.minutes) + expire_time.present? && Time.zone.now + buffer > expire_time + end + + # Exchanges the stored refresh token for a new access token and persists it. + # Returns the new access token, or nil when there is no refresh token or + # Canvas rejects the refresh (e.g. the user revoked Flextensions' access). + # + # When Canvas reports the refresh token itself is dead (invalid_grant), the + # credential is deleted so nothing keeps retrying it: the user simply goes + # through OAuth again on their next visit, and auto-approval stops offering + # them as a candidate. Transient failures (network errors, outages, a + # misconfigured client) leave the credential untouched. + def refresh! + return nil if refresh_token.blank? + + client = OAuth2::Client.new( + ENV.fetch('CANVAS_CLIENT_ID', nil), + ENV.fetch('CANVAS_APP_KEY', nil), + site: ENV.fetch('CANVAS_URL', nil), + token_url: '/login/oauth2/token' + ) + new_token = OAuth2::AccessToken.from_hash(client, refresh_token: refresh_token).refresh! + + update( + token: new_token.token, + # Canvas does not always rotate refresh tokens; keep the old one then. + refresh_token: new_token.refresh_token || refresh_token, + expire_time: Time.zone.at(new_token.expires_at) + ) + new_token.token + rescue OAuth2::Error => e + if e.code == 'invalid_grant' + # Canvas no longer recognizes this refresh token: the user revoked + # Flextensions under Settings > Approved Integrations, or the developer + # key/scopes changed (which invalidates every token derived from the + # key). It can never work again, so remove it rather than retry it. + Rails.logger.warn "Removing revoked #{lms.lms_name} credential for user #{user_id} (invalid_grant)" + destroy + else + Rails.logger.error "Failed to refresh #{lms.lms_name} token for user #{user_id}: #{e.message}" + Rails.error.report(e, handled: true, + context: { component: 'token_refresh', lms: lms.lms_name, user_id: user_id }) + end + nil + end end diff --git a/app/models/request.rb b/app/models/request.rb index 68ab713c..9c2da362 100644 --- a/app/models/request.rb +++ b/app/models/request.rb @@ -33,6 +33,8 @@ # fk_rails_... (last_processed_by_user_id => users.id) # fk_rails_... (user_id => users.id) # +require 'csv' + class Request < ApplicationRecord belongs_to :course belongs_to :assignment @@ -42,6 +44,7 @@ class Request < ApplicationRecord delegate :form_setting, to: :course, allow_nil: true validates :requested_due_date, :reason, presence: true + scope :pending, -> { where(status: 'pending') } scope :for_user, ->(user) { where(user: user).includes(:assignment) } scope :approved_for_user_in_course, lambda { |user, course| where(user: user, course: course, status: 'approved') @@ -50,6 +53,21 @@ class Request < ApplicationRecord where(user: user, course: course, status: 'approved', auto_approved: true) } + def pending? + status == 'pending' + end + + # Provisions the extension for this request through the assignment's LMS, + # acting on behalf of acting_user. Returns false when the assignment has no + # LMS facade. Shared by the single approve action and mass-approval so the + # facade lookup lives on the model rather than in the controller. + def approve_by(acting_user) + facade = assignment&.lms_facade + return false unless facade + + approve(facade.from_user(acting_user), acting_user) + end + # Class methods def self.merge_date_and_time!(request_params) return unless request_params[:requested_due_date].present? && request_params[:due_time].present? @@ -66,6 +84,7 @@ def process_created_request(current_user) slack_message, result = build_created_slack_and_result(:auto_approved, link) else slack_message, result = build_created_slack_and_result(:pending, link) + slack_message += auto_approval_breakdown_slack_note if auto_approval_breakdown end notify_slack(slack_message) @@ -74,43 +93,56 @@ def process_created_request(current_user) end # Handle request update and check for auto-approval - def process_update(_current_user) - link = request_link - notify_slack = true - - if status == 'pending' && try_auto_approval(_current_user) - slack_message = build_slack_message(:auto_approved, link) - result = build_result_hash('Your request was updated and has been approved.') + def process_update(current_user) + if status == 'pending' && try_auto_approval(current_user) + notify_slack(build_slack_message(:auto_approved, request_link)) + build_result_hash('Your request was updated and has been approved.') else - slack_message = build_slack_message(:updated, link) - result = build_result_hash('Request was successfully updated.') + slack_message = build_slack_message(:updated, request_link) + slack_message += auto_approval_breakdown_slack_note if auto_approval_breakdown + notify_slack(slack_message) + build_result_hash('Request was successfully updated.') end - - success = SlackNotifier.notify(slack_message, course.course_settings.slack_webhook_url) if notify_slack && course&.course_settings&.slack_webhook_url.present? - Rails.logger.error "Failed to send Slack notification for request #{id} in course #{course.id}. Please check your webhook URL." unless success - result end def calculate_days_difference (requested_due_date.to_date - assignment.due_date.to_date).to_i end - # Attempt to auto-approve by posting to the LMS. + # Set when a request met the auto-approval rules but could not be approved + # because no staff member's Canvas access worked; used to warn course staff. + attr_reader :auto_approval_breakdown + + # Attempt to auto-approve by posting to the LMS. Credentials on file can be + # stale (Canvas revokes refresh tokens that go unused for months) and a + # staff member may have left the Canvas course, so when one staff user's + # token cannot be refreshed or the LMS rejects the approval, fall back to + # the next staff user rather than giving up. def try_auto_approval(_current_user) - return false unless auto_approval_eligible_for_course? return false unless eligible_for_auto_approval? - approval_user = course.staff_user_for_auto_approval - approval_user.ensure_fresh_canvas_token! - return false if approval_user.canvas_credentials.blank? + candidates = course.staff_users_for_auto_approval + if candidates.empty? + flag_auto_approval_breakdown('no staff member has connected a Canvas account') + return false + end + + candidates.each do |approval_user| + if approval_user.ensure_fresh_canvas_token!.blank? + Rails.logger.warn "Auto-approval for request #{id}: could not refresh the Canvas token for staff user #{approval_user.id}; trying the next staff user." + next + end + + return true if auto_approve(assignment.lms_facade.from_user(approval_user)) - lms_facade_from_user = assignment.lms_facade.from_user(approval_user) - auto_approve(lms_facade_from_user) + Rails.logger.warn "Auto-approval for request #{id}: the LMS rejected the approval as staff user #{approval_user.id}; trying the next staff user." + end + + flag_auto_approval_breakdown("no staff member's Canvas access is currently working") + false end def auto_approval_eligible_for_course? - return false if course&.course_settings.blank? - course.course_settings.automatic_approval_enabled? end @@ -118,10 +150,14 @@ def eligible_for_auto_approval? return false unless auto_approval_eligible_for_course? return false unless meets_min_hours_before_deadline? - enrollment = UserToCourse.find_by(user: user, course: course) + enrollment = Enrollment.find_by(user: user, course: course) return false if enrollment.nil? if enrollment.allow_extended_requests - max_days = course.course_settings.auto_approve_extended_request_days + # Extended-request students get at least the standard window; a course + # that leaves auto_approve_extended_request_days at 0 must not exclude + # them from the auto-approval every other student gets. + max_days = [ course.course_settings.auto_approve_extended_request_days, + course.course_settings.auto_approve_days ].max else max_days = course.course_settings.auto_approve_days end @@ -140,24 +176,23 @@ def eligible_for_auto_approval? # least the configured number of hours before the assignment's deadline. With # a value of 0 (the default), this simply requires the deadline to not yet # have passed. - # - # Only called after auto_approval_eligible_for_course?, so course_settings is - # guaranteed present; an assignment always has a due_date. We intentionally do - # not guard those "impossible" cases with a permissive default -- letting them - # raise surfaces the bug rather than silently auto-approving. def meets_min_hours_before_deadline? settings = course.course_settings return true unless settings.enable_min_hours_before_deadline hours_until_deadline = (assignment.due_date - Time.current) / 1.hour - hours_until_deadline >= settings.min_hours_before_deadline.to_i + met = hours_until_deadline >= settings.min_hours_before_deadline.to_i + unless met + Rails.logger.info "Auto-approval skipped for request #{id}: #{hours_until_deadline.round(1)}h until deadline " \ + "is under the #{settings.min_hours_before_deadline.to_i}h minimum for course #{course.id}." + end + met end + # Approves the request as the system user and marks it auto-approved. + # Eligibility is the caller's responsibility (see try_auto_approval). def auto_approve(lms_facade_from_user) - return false unless eligible_for_auto_approval? - system_user = SystemUserService.ensure_auto_approval_user_exists - return false unless system_user # Reuse the regular approve method but mark as auto-approved afterward result = approve(lms_facade_from_user, system_user) @@ -188,7 +223,9 @@ def approve(lms_facade, processed_user_id) dates[:late_due_date]&.iso8601 ) rescue => e - Rails.logger.error "Error during LMS extension provisioning: #{e.message}" + Rails.logger.error "Error during LMS extension provisioning for request #{id}: #{e.message}" + Rails.error.report(e, handled: true, + context: { component: 'lms_provisioning', request_id: id, course_id: course_id, lms: lms_facade.class.name }) self.errors.add(:base, 'Failed to provision extension in LMS.') self.errors.add(:base, e.message) return false @@ -198,7 +235,7 @@ def approve(lms_facade, processed_user_id) status: 'approved', last_processed_by_user_id: processed_user_id.id, external_extension_id: override&.id) - send_email_response if course.course_settings&.enable_emails + send_email_response if course.course_settings.enable_emails true end @@ -221,7 +258,7 @@ def calculate_new_late_due_date def reject(processed_user_id) update(status: 'denied', last_processed_by_user_id: processed_user_id.id) # Only send email if the person processing is the same as the request's user - send_email_response if course.course_settings&.enable_emails && processed_user_id.id != user_id + send_email_response if course.course_settings.enable_emails && processed_user_id.id != user_id true end @@ -245,7 +282,7 @@ def calculate_new_assignment_dates end def send_email_response - return unless course.course_settings&.enable_emails + return unless course.course_settings.enable_emails cs = course.course_settings to = user.email @@ -294,6 +331,17 @@ def self.to_csv(requests) private + def flag_auto_approval_breakdown(reason) + @auto_approval_breakdown = reason + Rails.logger.warn "Auto-approval broken for request #{id} in course #{course.id}: #{reason}. " \ + 'A staff member must log in to Flextensions to reconnect Canvas.' + end + + def auto_approval_breakdown_slack_note + "\n:warning: This request met the auto-approval rules, but #{auto_approval_breakdown}. " \ + 'A staff member should log in to Flextensions to reconnect Canvas, then approve pending requests manually.' + end + def build_slack_message(type, link) case type when :auto_approved @@ -332,7 +380,7 @@ def build_created_slack_and_result(type, link) end def notify_slack(slack_message) - return if course&.course_settings&.slack_webhook_url.blank? + return if course.course_settings.slack_webhook_url.blank? success = SlackNotifier.notify(slack_message, course.course_settings.slack_webhook_url) Rails.logger.error "Failed to send Slack notification for request #{id} in course #{course.id}. Please check your webhook URL." unless success diff --git a/app/models/user.rb b/app/models/user.rb index 6695a9a7..c73bf711 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -20,7 +20,7 @@ class User < ApplicationRecord has_many :requests, dependent: :nullify # This association is for when a request is processed by a different user: - has_many :processed_requests, class_name: 'Request', foreign_key: 'last_processed_by_id', inverse_of: :last_processed_by + has_many :processed_requests, class_name: 'Request', foreign_key: 'last_processed_by_user_id', inverse_of: :last_processed_by_user # NOTE: Validations are skipped when a User is created by SyncUsersFromCanvasJob # You should update that job if these validations become complex. @@ -34,46 +34,25 @@ class User < ApplicationRecord # Relationship with Extension has_many :extensions - # Relationship with Course (and UserToCourse) - has_many :user_to_courses - has_many :courses, through: :user_to_courses + # Relationship with Course (and Enrollment) + has_many :enrollments + has_many :courses, through: :enrollments def canvas_credentials lms_credentials.find_by(lms_id: Lms.CANVAS_LMS.id) end - def token_expired? - return false unless lms_credentials.any? - - lms_credentials.each do |credential| - return true if Time.zone.now > credential.expire_time - end - - false - end - - # Check if token will expire within the specified buffer time - def token_expires_soon?(buffer_minutes = 15) - return false unless lms_credentials.any? - - lms_credentials.each do |credential| - return true if credential.expire_time && Time.zone.now + buffer_minutes.minutes > credential.expire_time - end - - false - end - - # Get active token or refresh if needed + # Returns a Canvas access token that is valid for at least the next few + # minutes, refreshing it first when it is about to expire. Returns nil when + # the user has no Canvas credential or the refresh fails (e.g. Canvas + # revoked the refresh token after months of inactivity) -- callers must + # treat nil as "cannot call Canvas as this user" rather than proceeding + # with a stale token. def ensure_fresh_canvas_token! - return nil unless lms_credentials.any? - - credential = lms_credentials.first - - if token_expires_soon? - # Call the refresh token method from SessionController - SessionController.new.send(:refresh_user_token, self) - end + credential = canvas_credentials + return nil if credential.nil? + return credential.token unless credential.expires_soon? - credential.token + credential.refresh! end end diff --git a/app/services/assignment_date_calculator.rb b/app/services/assignment_date_calculator.rb index 22b75144..b7b2e2c4 100644 --- a/app/services/assignment_date_calculator.rb +++ b/app/services/assignment_date_calculator.rb @@ -16,7 +16,7 @@ class AssignmentDateCalculator # @param assignment [Assignment] The assignment being extended # @param request [Request] The extension request with the new requested_due_date - # @param course_settings [CourseSettings, nil] The course settings (may be nil) + # @param course_settings [CourseSettings] The course settings def initialize(assignment:, request:, course_settings:) @assignment = assignment @request = request @@ -52,7 +52,7 @@ def late_due_date original_late_due_date = assignment.late_due_date return nil if original_late_due_date.blank? - if extend_late_due_date? + if course_settings.extend_late_due_date # Shift the late due date by the same delta as the extension extension_delta = request.requested_due_date - assignment.due_date original_late_due_date + extension_delta @@ -61,15 +61,4 @@ def late_due_date [ original_late_due_date, request.requested_due_date ].max end end - - private - - # Determines whether to extend the late due date by the same delta as the extension. - # Defaults to true if the setting is nil (for backwards compatibility). - # @return [Boolean] - def extend_late_due_date? - setting = course_settings&.extend_late_due_date - # Default to true if setting is nil (for backwards compatibility) - setting.nil? ? true : setting - end end diff --git a/app/services/email_service.rb b/app/services/email_service.rb index f6ebcced..96eefd89 100644 --- a/app/services/email_service.rb +++ b/app/services/email_service.rb @@ -27,13 +27,12 @@ def render_templates(subject_template, body_template, mapping) def send_email(to:, from:, reply_to:, subject_template:, body_template:, mapping:, deliver_later: false) rendered = render_templates(subject_template, body_template, mapping) - mail = ActionMailer::Base.mail( + mail = TemplatedMailer.templated_email( to: to, from: from, reply_to: reply_to, subject: rendered[:subject], - body: rendered[:body].gsub("\n", "
\n"), - content_type: 'text/html' + body: rendered[:body].gsub("\n", "
\n") ) deliver_later ? mail.deliver_later : mail.deliver_now diff --git a/app/services/request_service.rb b/app/services/request_service.rb deleted file mode 100644 index 59c08c77..00000000 --- a/app/services/request_service.rb +++ /dev/null @@ -1,57 +0,0 @@ -class RequestService - # Handle authentication for a specific course - def self.authenticate_course(course, redirect_path) - return true if course - - { redirect_to: redirect_path, alert: 'Course not found.' } - end - - # Check instructor permission - def self.check_instructor_permission(role, course_path) - return true if role == 'instructor' - - { redirect_to: course_path, alert: 'You do not have permission to perform this action.' } - end - - # Ensure extensions are enabled for students - def self.check_extensions_enabled_for_students(role, course, redirect_path) - return true unless role == 'student' - - course_settings = course.course_settings - return true unless course_settings && !course_settings.enable_extensions - - { redirect_to: redirect_path, alert: 'Extensions are not enabled for this course.' } - end - - # Ensure a request is in pending status - def self.ensure_request_is_pending(request, course_path) - if request.nil? - { redirect_to: course_path, alert: 'Request not found.' } - elsif request.status != 'pending' - { redirect_to: course_path, alert: 'This action can only be performed on pending requests.' } - else - true - end - end - - # Set course role from settings - def self.set_course_role_from_settings(course_id, user) - course = Course.find_by(id: course_id) - if course - role = course.user_role(user) - form_settings = course&.form_setting - { course: course, role: role, form_settings: form_settings } - else - { redirect_to: Rails.application.routes.url_helpers.courses_path, alert: 'Course not found.' } - end - end - - # TODO: Deprecate this and move to ApplicationController. - def self.authenticate_user(session_user_id) - user = User.find_by(canvas_uid: session_user_id) - return { user: user } if user - - { redirect_to: Rails.application.routes.url_helpers.root_path, - alert: 'Please log in to access this page.' } - end -end diff --git a/app/services/slack_notifier.rb b/app/services/slack_notifier.rb index 665da187..519adf5a 100644 --- a/app/services/slack_notifier.rb +++ b/app/services/slack_notifier.rb @@ -24,6 +24,7 @@ def self.notify(text, webhook_url) true rescue StandardError => e Rails.logger.error("SlackNotifier exception: #{e.class} - #{e.message}") + Rails.error.report(e, handled: true, context: { component: 'slack_notifier' }) false end end diff --git a/app/views/courses/edit.html.erb b/app/views/courses/edit.html.erb index f3d130cb..8502e5ec 100644 --- a/app/views/courses/edit.html.erb +++ b/app/views/courses/edit.html.erb @@ -47,7 +47,7 @@ <%= hidden_field_tag 'course_settings[enable_extensions]', false %> <%= check_box_tag 'course_settings[enable_extensions]', true, - @course.course_settings&.enable_extensions, + @course.course_settings.enable_extensions, class: 'form-check-input', id: 'course-enable' %> @@ -58,7 +58,7 @@
<%= number_field_tag 'course_settings[auto_approve_days]', - @course.course_settings&.auto_approve_days, + @course.course_settings.auto_approve_days, min: 0, step: 1, class: 'form-control', @@ -74,7 +74,7 @@
<%= number_field_tag 'course_settings[auto_approve_extended_request_days]', - @course.course_settings&.auto_approve_extended_request_days, + @course.course_settings.auto_approve_extended_request_days, min: 0, step: 1, class: 'form-control', @@ -90,7 +90,7 @@
<%= number_field_tag 'course_settings[max_auto_approve]', - @course.course_settings&.max_auto_approve, + @course.course_settings.max_auto_approve, min: 0, step: 1, class: 'form-control', @@ -106,7 +106,7 @@ <%= 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, + @course.course_settings.enable_min_hours_before_deadline, class: 'form-check-input', id: 'enable-min-hours-before-deadline' %> @@ -117,7 +117,7 @@
<%= number_field_tag 'course_settings[min_hours_before_deadline]', - @course.course_settings&.min_hours_before_deadline || 0, + @course.course_settings.min_hours_before_deadline, min: 0, step: 1, class: 'form-control', @@ -135,7 +135,7 @@ <%= 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, + @course.course_settings.extend_late_due_date, class: 'form-check-input', id: 'extend-late-due-date' %> @@ -151,7 +151,7 @@ <%= hidden_field_tag 'course_settings[enable_gradescope]', false %> <%= check_box_tag 'course_settings[enable_gradescope]', true, - @course.course_settings&.enable_gradescope, + @course.course_settings.enable_gradescope, class: 'form-check-input', id: 'enable-gradescope' %> @@ -162,12 +162,12 @@
<%= url_field_tag 'course_settings[gradescope_course_url]', - @course.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, + 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)' %> @@ -195,7 +195,7 @@ <%= hidden_field_tag 'course_settings[enable_emails]', false %> <%= check_box_tag 'course_settings[enable_emails]', true, - @course.course_settings&.enable_emails, + @course.course_settings.enable_emails, class: 'form-check-input', id: 'enable-email' %> @@ -206,12 +206,12 @@
<%= email_field_tag 'course_settings[reply_email]', - @course.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 %> + disabled: !@course.course_settings.enable_emails %>
@@ -220,7 +220,7 @@ <%= 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, + @course.course_settings.enable_slack_webhook_url, class: 'form-check-input', id: 'enable-slack' %> @@ -231,12 +231,12 @@
<%= text_field_tag 'course_settings[slack_webhook_url]', - @course.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 %> + disabled: !@course.course_settings.enable_slack_webhook_url %>
@@ -270,7 +270,7 @@
- <% if @course.course_settings&.enable_extensions %> + <% 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', @@ -302,7 +302,7 @@
<%= text_field_tag 'course_settings[email_subject]', - @course.course_settings&.email_subject || "Extension Request Status: {{status}} - {{course_code}}", + @course.course_settings.email_subject, class: 'form-control', id: 'email-subject' %> Use {{variable_name}} to insert dynamic values @@ -311,7 +311,7 @@
<%= text_area_tag 'course_settings[email_template]', - @course.course_settings&.email_template || "Dear {{student_name}},\n\nYour 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", + @course.course_settings.email_template, class: 'form-control', id: 'email-template', rows: 10 %> diff --git a/app/views/courses/enrollments.html.erb b/app/views/courses/enrollments.html.erb index 12e577bd..2a3a31fd 100644 --- a/app/views/courses/enrollments.html.erb +++ b/app/views/courses/enrollments.html.erb @@ -65,7 +65,7 @@ data-enrollments-target="checkbox" data-action="change->enrollments#toggleExtended" data-enrollment-id="<%= enrollment.id %>" - data-url="<%= toggle_allow_extended_requests_course_user_to_course_path(@course, enrollment) %>"> + data-url="<%= toggle_allow_extended_requests_course_enrollment_path(@course, enrollment) %>"> diff --git a/app/views/courses/index.html.erb b/app/views/courses/index.html.erb index 09a2c713..617806a5 100644 --- a/app/views/courses/index.html.erb +++ b/app/views/courses/index.html.erb @@ -25,15 +25,15 @@ - <% @teacher_courses_by_semester.each do |semester, user_to_courses| %> + <% @teacher_courses_by_semester.each do |semester, enrollments| %> <%= semester || "Unknown Semester" %> - <% user_to_courses.each do |user_to_course| %> + <% enrollments.each do |enrollment| %> - <%= user_to_course.course.course_name %> - <%= user_to_course.course.course_code %> - <%= user_to_course.display_role %> + <%= enrollment.course.course_name %> + <%= enrollment.course.course_code %> + <%= enrollment.display_role %> <% end %> <% end %> @@ -61,14 +61,14 @@ - <% @student_courses_by_semester.each do |semester, user_to_courses| %> + <% @student_courses_by_semester.each do |semester, enrollments| %> <%= semester || "Unknown Semester" %> - <% user_to_courses.each do |user_to_course| %> + <% enrollments.each do |enrollment| %> - <%= user_to_course.course.course_name %> - <%= user_to_course.course.course_code %> + <%= enrollment.course.course_name %> + <%= enrollment.course.course_code %> <% end %> <% end %> diff --git a/app/views/courses/new.html.erb b/app/views/courses/new.html.erb index 4b5fa74f..524dd14d 100644 --- a/app/views/courses/new.html.erb +++ b/app/views/courses/new.html.erb @@ -47,8 +47,8 @@ <%= course["course_code"] %> <%= course["name"] %> - <% staff_enrollment = course['enrollments'].find { |enrollment| UserToCourse.staff_enrollment?(enrollment) } %> - <%= UserToCourse.display_role(UserToCourse.role_from_canvas_enrollment(staff_enrollment)) %> + <% staff_enrollment = course['enrollments'].find { |enrollment| Enrollment.staff_enrollment?(enrollment) } %> + <%= Enrollment.display_role(Enrollment.role_from_canvas_enrollment(staff_enrollment)) %> <% end %> diff --git a/app/views/shared/_extension_status_warning.html.erb b/app/views/shared/_extension_status_warning.html.erb index 2d33be2d..c746d43c 100644 --- a/app/views/shared/_extension_status_warning.html.erb +++ b/app/views/shared/_extension_status_warning.html.erb @@ -1,9 +1,4 @@ -<% - course_settings = course.course_settings - extensions_enabled = course_settings.nil? || course_settings.enable_extensions -%> - -<% unless extensions_enabled %> +<% unless course.requests_enabled? %>
Flextensions is disabled for this course. To allow to students to see this course, please enable extensions in Course Settings.
diff --git a/config/application.rb b/config/application.rb index e298613f..09dc8b9d 100644 --- a/config/application.rb +++ b/config/application.rb @@ -21,7 +21,7 @@ module Flextensions class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. - config.load_defaults 7.2 + config.load_defaults 8.1 # Please, add to the `ignore` list any other `lib` subdirectories that do # not contain `.rb` files, or that should not be reloaded or eager loaded. diff --git a/config/initializers/faultline.rb b/config/initializers/faultline.rb new file mode 100644 index 00000000..a8bc09f7 --- /dev/null +++ b/config/initializers/faultline.rb @@ -0,0 +1,282 @@ +# frozen_string_literal: true + +Faultline.configure do |config| + # ============================================================================= + # User Configuration + # ============================================================================= + + # User class for association (default: "User") + config.user_class = "User" + + # Method to get current user in controllers + config.user_method = :current_user + + # Custom context - add extra data to every error occurrence + # Receives request and Rack env, should return a hash + # config.custom_context = lambda { |request, env| + # controller = env["action_controller.instance"] + # { + # account_id: controller&.current_account&.id, + # tenant: request.subdomain + # } + # } + + # ============================================================================= + # Error Filtering + # ============================================================================= + + # Exceptions to ignore (won't be tracked) + config.ignored_exceptions = [ + "ActiveRecord::RecordNotFound", + "ActionController::RoutingError", + "ActionController::UnknownFormat", + "ActionController::InvalidAuthenticityToken", + "ActionController::BadRequest" + ] + + # User agents to ignore (bots, crawlers) + config.ignored_user_agents = [ + /bot/i, /crawler/i, /spider/i, /Googlebot/i, /Bingbot/i, /Slurp/i + ] + + # ============================================================================= + # Dashboard Authentication + # ============================================================================= + + # Gate the dashboard to admins only. + # + # This lambda is instance_exec'd inside Faultline's own controller, which does + # not have the app's `current_user` helper, so we resolve the user the same way + # ApplicationController#current_user does (User.find_by(canvas_uid: + # session[:user_id])) and then require `admin?`. + config.authenticate_with = lambda { |request| + user = User.find_by(canvas_uid: request.session[:user_id]) + !!user&.admin? + } + + # Optional: Additional authorization after authentication + # config.authorize_with = lambda { |request| + # # Add extra authorization logic here + # true + # } + + # ============================================================================= + # Notifications + # ============================================================================= + + # App name for notifications (shown in alert messages) + config.app_name = Rails.application.class.module_parent_name + + # Notification rules - when to send alerts + config.notification_rules = { + on_first_occurrence: true, # Alert on new error types + on_reopen: true, # Alert when resolved errors reoccur + on_threshold: [10, 50, 100, 500], # Alert at these occurrence counts + critical_exceptions: [], # Always alert for these exception classes + notify_in_environments: ["production"] + } + + # --- Telegram Notifier --- + # Store credentials in Rails credentials: + # rails credentials:edit + # faultline: + # telegram: + # bot_token: "your-bot-token" + # chat_id: "your-chat-id" + # message_thread_id: 123 # optional, for forum topics + # + # if Rails.application.credentials.dig(:faultline, :telegram, :bot_token) + # config.add_notifier( + # Faultline::Notifiers::Telegram.new( + # bot_token: Rails.application.credentials.dig(:faultline, :telegram, :bot_token), + # chat_id: Rails.application.credentials.dig(:faultline, :telegram, :chat_id), + # message_thread_id: Rails.application.credentials.dig(:faultline, :telegram, :message_thread_id) + # ) + # ) + # end + + # --- Slack Notifier --- + # Store webhook URL in Rails credentials: + # rails credentials:edit + # faultline: + # slack: + # webhook_url: "https://hooks.slack.com/services/..." + # + # if Rails.application.credentials.dig(:faultline, :slack, :webhook_url) + # config.add_notifier( + # Faultline::Notifiers::Slack.new( + # webhook_url: Rails.application.credentials.dig(:faultline, :slack, :webhook_url), + # channel: "#errors", + # username: "Faultline" + # ) + # ) + # end + + # --- Discord Notifier --- + # Store webhook URL in Rails credentials: + # rails credentials:edit + # faultline: + # discord: + # webhook_url: "https://discord.com/api/webhooks/..." + # + # if Rails.application.credentials.dig(:faultline, :discord, :webhook_url) + # config.add_notifier( + # Faultline::Notifiers::Discord.new( + # webhook_url: Rails.application.credentials.dig(:faultline, :discord, :webhook_url), + # username: "Faultline", + # avatar_url: nil, # optional, overrides the webhook's avatar + # mention: nil # optional, e.g. "<@&ROLE_ID>" or "@everyone" + # ) + # ) + # end + + # --- Generic Webhook Notifier --- + # For custom integrations (PagerDuty, Opsgenie, etc.) + # + # config.add_notifier( + # Faultline::Notifiers::Webhook.new( + # url: ENV["FAULTLINE_WEBHOOK_URL"], + # method: :post, + # headers: { "Authorization" => "Bearer #{ENV['FAULTLINE_WEBHOOK_TOKEN']}" } + # ) + # ) + + # --- Resend Email Notifier --- + # Sends error notifications via Resend API (https://resend.com) + # Store API key in Rails credentials: + # rails credentials:edit + # faultline: + # resend: + # api_key: "re_xxxxx" + # + # if Rails.application.credentials.dig(:faultline, :resend, :api_key) + # config.add_notifier( + # Faultline::Notifiers::Resend.new( + # api_key: Rails.application.credentials.dig(:faultline, :resend, :api_key), + # from: "errors@yourdomain.com", + # to: "team@example.com" # or array: ["dev@example.com", "ops@example.com"] + # ) + # ) + # end + + # --- Email Notifier (ActionMailer) --- + # Sends error notifications using your app's existing mail configuration. + # Uses ActionMailer with deliver_later (requires Active Job). + # + # config.add_notifier( + # Faultline::Notifiers::Email.new( + # to: ["team@example.com", "oncall@example.com"], + # from: "errors@yourdomain.com" # optional, defaults to ActionMailer default + # ) + # ) + + # Notification cooldown - prevent spam during error storms (nil to disable) + config.notification_cooldown = 5.minutes + + # ============================================================================= + # GitHub Integration + # ============================================================================= + + # Create GitHub issues from error groups with full context. + # Store credentials in Rails credentials: + # rails credentials:edit + # faultline: + # github: + # token: "ghp_xxxxx" + # + # config.github_repo = "your-org/your-repo" + # config.github_token = Rails.application.credentials.dig(:faultline, :github, :token) + + # Labels to add to created issues (customize for your workflow) + # Add "faultline-auto-fix" to trigger AI auto-fix via GitHub Actions + # config.github_labels = ["bug", "faultline", "faultline-auto-fix"] + + # ============================================================================= + # Error Capture Configuration + # ============================================================================= + + # Enable Rack middleware to catch unhandled exceptions automatically + config.enable_middleware = true + + # Subscribe to Rails error reporting API (Rails.error.report/handle/record) + # This captures errors from background jobs and explicit Rails.error calls + config.register_error_subscriber = true + + # Paths to ignore (no error tracking for these) + config.middleware_ignore_paths = ["/assets", "/up", "/health", "/admin/errors"] + + # ============================================================================= + # Data Configuration + # ============================================================================= + + # Maximum backtrace lines to store per occurrence + config.backtrace_lines_limit = 50 + + # How long to keep error data in days (nil = forever) + # Consider setting up a cleanup job if you have high error volume + config.retention_days = 90 + + # ============================================================================= + # Callbacks (Advanced) + # ============================================================================= + + # Before tracking - return false to skip tracking this error + # config.before_track = lambda { |exception, context| + # # Example: Skip timeout errors + # return false if exception.message.include?("Timeout") + # true + # } + + # After tracking - for custom integrations + # config.after_track = lambda { |error_group, occurrence| + # # Example: Send to analytics + # Analytics.track("error_occurred", { + # exception: error_group.exception_class, + # count: error_group.occurrences_count + # }) + # } + + # Custom fingerprinting - control how errors are grouped + # config.custom_fingerprint = lambda { |exception, context| + # # Example: Group by feature flag + # { extra_components: [context.dig(:custom_data, :feature_flag)] } + # } + + # ============================================================================= + # Application Performance Monitoring (APM) + # ============================================================================= + + # Enable basic APM to track request performance metrics. + # Captures response times, database queries, and throughput per endpoint. + # config.enable_apm = true + + # Sample rate for high-traffic apps (0.0 to 1.0, default: 1.0 = every request) + # config.apm_sample_rate = 1.0 + + # Paths to ignore for APM (defaults to middleware_ignore_paths if nil) + # config.apm_ignore_paths = ["/assets", "/up", "/health", "/faultline"] + + # How long to keep APM traces in days (default: 30) + # Use `rake faultline:apm:cleanup` to remove old traces. + # config.apm_retention_days = 30 + + # --- Span Collection (Waterfall Visualization) --- + # Capture detailed spans for SQL, HTTP, Redis, and view rendering. + # When enabled, traces include a waterfall timeline showing each operation. + # config.apm_capture_spans = true # default: true when APM enabled + + # --- CPU Profiling (Flame Graphs) --- + # Enable sampling-based profiling using stackprof for flame graph visualization. + # Requires: gem 'stackprof' in your Gemfile + # config.apm_enable_profiling = false # disabled by default + + # Profile only a sample of requests to minimize overhead (0.0 to 1.0) + # config.apm_profile_sample_rate = 0.1 # 10% of requests + + # Profiler sampling interval in microseconds (default: 1000 = 1ms) + # Lower values = more detailed profiles but higher overhead + # config.apm_profile_interval = 1000 + + # Profile mode: :cpu (CPU time), :wall (wall clock), or :object (allocations) + # config.apm_profile_mode = :cpu +end diff --git a/config/initializers/lograge.rb b/config/initializers/lograge.rb index 25af3a01..4fe89b35 100644 --- a/config/initializers/lograge.rb +++ b/config/initializers/lograge.rb @@ -25,7 +25,10 @@ config.lograge.custom_payload do |controller| { request_id: controller.request.uuid, - user_id: controller.current_user.try(:id) + # Guard the method call itself: mounted engine controllers (e.g. + # Faultline::ErrorGroupsController) inherit from ActionController::Base + # and do not define `current_user`, so calling it directly would raise. + user_id: controller.try(:current_user).try(:id) } end diff --git a/config/routes.rb b/config/routes.rb index 2a0448d9..85d9a2e7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,5 @@ Rails.application.routes.draw do + mount Faultline::Engine, at: "/admin/errors" if Rails.env.development? mount LetterOpenerWeb::Engine, at: "/letter_opener" end @@ -44,10 +45,10 @@ post :create_for_student post :mass_approve post :mass_reject - get :export, defaults: { format: :csv } + get :export, to: 'requests/exports#show', defaults: { format: :csv } end end - resources :user_to_courses, only: [] do + resources :enrollments, only: [] do member do patch :toggle_allow_extended_requests end diff --git a/db/api_spec_seeds.rb b/db/api_spec_seeds.rb index 50748302..b52de76c 100644 --- a/db/api_spec_seeds.rb +++ b/db/api_spec_seeds.rb @@ -6,7 +6,7 @@ Extension.destroy_all Assignment.destroy_all CourseToLms.destroy_all -UserToCourse.destroy_all +Enrollment.destroy_all Course.destroy_all User.destroy_all @@ -33,7 +33,7 @@ email: "testuser@example.com", }) -test_user_to_course = UserToCourse.create!({ +test_enrollment = Enrollment.create!({ user_id: test_user.id, course_id: test_course.id, role: "test", diff --git a/db/migrate/20260702000001_add_course_id_to_assignments.rb b/db/migrate/20260702000001_add_course_id_to_assignments.rb new file mode 100644 index 00000000..d00dc1a6 --- /dev/null +++ b/db/migrate/20260702000001_add_course_id_to_assignments.rb @@ -0,0 +1,25 @@ +class AddCourseIdToAssignments < ActiveRecord::Migration[7.2] + # Assignments were only reachable from a course through course_to_lmss, + # so every course-scoped assignment query paid for a join. Denormalizing + # course_id onto assignments lets those queries hit an indexed FK directly. + def up + safety_assured do + add_reference :assignments, :course, foreign_key: true, index: true + + # Backfill from the existing join table. course_to_lms_id is NOT NULL and + # CourseToLms requires a course, so this covers every row. + execute <<~SQL.squish + UPDATE assignments + SET course_id = course_to_lmss.course_id + FROM course_to_lmss + WHERE assignments.course_to_lms_id = course_to_lmss.id + SQL + + change_column_null :assignments, :course_id, false + end + end + + def down + remove_reference :assignments, :course, foreign_key: true + end +end diff --git a/db/migrate/20260702035829_create_faultline_error_groups.rb b/db/migrate/20260702035829_create_faultline_error_groups.rb new file mode 100644 index 00000000..1dc182fe --- /dev/null +++ b/db/migrate/20260702035829_create_faultline_error_groups.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class CreateFaultlineErrorGroups < ActiveRecord::Migration[7.2] + # Whole migration asserted safe: it creates a brand-new table and adds a + # PostgreSQL generated `searchable` tsvector column via raw SQL. strong_migrations + # cannot inspect execute() calls, and every operation here targets the table + # created in this same migration, so there is nothing unsafe to guard against. + def change + safety_assured do + create_table :faultline_error_groups do |t| + t.string :fingerprint, null: false + t.string :exception_class, null: false + t.text :sanitized_message, null: false + t.string :file_path + t.integer :line_number + t.string :method_name + + t.integer :occurrences_count, default: 0 + t.datetime :first_seen_at + t.datetime :last_seen_at + t.string :status, default: "unresolved" + t.datetime :resolved_at + t.datetime :last_notified_at + + t.timestamps + end + + add_index :faultline_error_groups, :fingerprint, unique: true + add_index :faultline_error_groups, :exception_class + add_index :faultline_error_groups, :status + add_index :faultline_error_groups, :last_seen_at + + # PostgreSQL: full-text search column with GIN index. + if postgresql? + execute <<-SQL.squish + ALTER TABLE faultline_error_groups + ADD COLUMN searchable tsvector + GENERATED ALWAYS AS ( + to_tsvector('simple', + coalesce(exception_class, '') || ' ' || + coalesce(sanitized_message, '') || ' ' || + coalesce(file_path, '') + ) + ) STORED; + SQL + + add_index :faultline_error_groups, :searchable, using: :gin + end + end + end + + private + + def postgresql? + ActiveRecord::Base.connection.adapter_name.downcase.include?("postgresql") + end +end diff --git a/db/migrate/20260702035830_create_faultline_error_occurrences.rb b/db/migrate/20260702035830_create_faultline_error_occurrences.rb new file mode 100644 index 00000000..296e417f --- /dev/null +++ b/db/migrate/20260702035830_create_faultline_error_occurrences.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class CreateFaultlineErrorOccurrences < ActiveRecord::Migration[7.2] + def change + create_table :faultline_error_occurrences do |t| + t.references :error_group, null: false, foreign_key: { to_table: :faultline_error_groups } + + t.string :exception_class, null: false + t.text :message, null: false + t.text :backtrace + + t.string :request_method + t.string :request_url + t.text :request_params + t.text :request_headers + t.string :user_agent + t.string :ip_address + + t.bigint :user_id + t.string :user_type + t.string :session_id + + t.string :environment + t.string :hostname + t.string :process_id + + t.json :local_variables + + t.timestamps + end + + add_index :faultline_error_occurrences, :created_at + add_index :faultline_error_occurrences, [ :error_group_id, :created_at ] + add_index :faultline_error_occurrences, [ :user_type, :user_id ] + end +end diff --git a/db/migrate/20260702035831_create_faultline_error_contexts.rb b/db/migrate/20260702035831_create_faultline_error_contexts.rb new file mode 100644 index 00000000..2e1616c9 --- /dev/null +++ b/db/migrate/20260702035831_create_faultline_error_contexts.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateFaultlineErrorContexts < ActiveRecord::Migration[7.2] + def change + create_table :faultline_error_contexts do |t| + t.references :error_occurrence, null: false, foreign_key: { to_table: :faultline_error_occurrences } + t.string :key, null: false + t.text :value + + t.timestamps + end + + add_index :faultline_error_contexts, [ :error_occurrence_id, :key ] + end +end diff --git a/db/migrate/20260702035832_create_faultline_request_traces.rb b/db/migrate/20260702035832_create_faultline_request_traces.rb new file mode 100644 index 00000000..39cac163 --- /dev/null +++ b/db/migrate/20260702035832_create_faultline_request_traces.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CreateFaultlineRequestTraces < ActiveRecord::Migration[7.2] + def change + create_table :faultline_request_traces do |t| + t.string :endpoint, null: false + t.string :http_method, null: false + t.string :path + t.integer :status + t.float :duration_ms + t.float :db_runtime_ms + t.float :view_runtime_ms + t.integer :db_query_count, default: 0 + + t.datetime :created_at, null: false + end + + add_index :faultline_request_traces, :endpoint + add_index :faultline_request_traces, :created_at + add_index :faultline_request_traces, [ :endpoint, :created_at ] + end +end diff --git a/db/migrate/20260702035833_add_spans_to_faultline_request_traces.rb b/db/migrate/20260702035833_add_spans_to_faultline_request_traces.rb new file mode 100644 index 00000000..8105f7e7 --- /dev/null +++ b/db/migrate/20260702035833_add_spans_to_faultline_request_traces.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddSpansToFaultlineRequestTraces < ActiveRecord::Migration[7.2] + # Whole migration asserted safe. strong_migrations flags `add_column … :json` + # (it prefers `jsonb`); faultline ships this column as `:json`, and we keep the + # gem's schema rather than diverging. Both column adds are safe on PostgreSQL 11+. + def change + safety_assured do + add_column :faultline_request_traces, :spans, :json + add_column :faultline_request_traces, :has_profile, :boolean, default: false + end + end +end diff --git a/db/migrate/20260702035834_create_faultline_request_profiles.rb b/db/migrate/20260702035834_create_faultline_request_profiles.rb new file mode 100644 index 00000000..33bc04b7 --- /dev/null +++ b/db/migrate/20260702035834_create_faultline_request_profiles.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateFaultlineRequestProfiles < ActiveRecord::Migration[7.2] + def change + create_table :faultline_request_profiles do |t| + t.references :request_trace, null: false, foreign_key: { to_table: :faultline_request_traces, on_delete: :cascade } + t.text :profile_data, null: false + t.string :mode, default: 'cpu' + t.integer :samples, default: 0 + t.float :interval_ms + + t.datetime :created_at, null: false + end + end +end diff --git a/db/migrate/20260702035835_change_sanitized_message_to_text.rb b/db/migrate/20260702035835_change_sanitized_message_to_text.rb new file mode 100644 index 00000000..eb50d0fd --- /dev/null +++ b/db/migrate/20260702035835_change_sanitized_message_to_text.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class ChangeSanitizedMessageToText < ActiveRecord::Migration[7.2] + # OVERRIDE: + # + # On a fresh install this migration is a no-op AND impossible to run on + # PostgreSQL. The initial CreateFaultlineErrorGroups migration already creates + # `sanitized_message` as :text and adds a generated `searchable` tsvector + # column derived from it. Postgres then refuses to alter the column's type: + # PG::FeatureNotSupported: cannot alter type of a column used by a + # generated column ("sanitized_message" is used by generated column + # "searchable"). + # This migration only exists upstream to upgrade legacy installs that created + # the column as :string before the generated column was introduced. We guard + # it so it is skipped whenever the column is already the target type. + def up + return if column_exists?(:faultline_error_groups, :sanitized_message, :text) + + safety_assured do + change_column :faultline_error_groups, :sanitized_message, :text, null: false + end + end + + def down + return if column_exists?(:faultline_error_groups, :sanitized_message, :string) + + safety_assured do + change_column :faultline_error_groups, :sanitized_message, :string, null: false + end + end +end diff --git a/db/migrate/20260703000001_enforce_one_to_one_course_settings.rb b/db/migrate/20260703000001_enforce_one_to_one_course_settings.rb new file mode 100644 index 00000000..f139bd4f --- /dev/null +++ b/db/migrate/20260703000001_enforce_one_to_one_course_settings.rb @@ -0,0 +1,35 @@ +class EnforceOneToOneCourseSettings < ActiveRecord::Migration[7.2] + # Every course must have exactly one course_settings record. Courses created + # going forward get one via an after_create callback; this migration fixes up + # existing data and enforces the invariant at the database level. + def up + safety_assured do + # Remove duplicate settings rows, keeping the oldest one per course. + execute <<~SQL + DELETE FROM course_settings + WHERE id NOT IN ( + SELECT MIN(id) FROM course_settings GROUP BY course_id + ) + SQL + + # Backfill settings (with column defaults) for courses that have none. + execute <<~SQL + INSERT INTO course_settings (course_id, created_at, updated_at) + SELECT courses.id, NOW(), NOW() + FROM courses + LEFT JOIN course_settings ON course_settings.course_id = courses.id + WHERE course_settings.id IS NULL + SQL + + remove_index :course_settings, :course_id + add_index :course_settings, :course_id, unique: true + end + end + + def down + safety_assured do + remove_index :course_settings, :course_id + add_index :course_settings, :course_id + end + end +end diff --git a/db/migrate/20260704000001_rename_user_to_courses_to_enrollments.rb b/db/migrate/20260704000001_rename_user_to_courses_to_enrollments.rb new file mode 100644 index 00000000..a01ecc54 --- /dev/null +++ b/db/migrate/20260704000001_rename_user_to_courses_to_enrollments.rb @@ -0,0 +1,5 @@ +class RenameUserToCoursesToEnrollments < ActiveRecord::Migration[7.2] + def change + safety_assured { rename_table :user_to_courses, :enrollments } + end +end diff --git a/db/schema.rb b/db/schema.rb index 294e0ec3..7f94ee3b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,12 +10,11 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_06_22_000001) do +ActiveRecord::Schema[8.1].define(version: 2026_07_04_000001) do create_schema "hypershield" - # These are extensions that must be enabled in order to support this database - enable_extension "plpgsql" + enable_extension "pg_catalog.plpgsql" # Custom types defined in this database. # Note that some types may not work with other database engines. Be careful if changing database. @@ -23,186 +22,286 @@ create_enum "request_status", ["pending", "approved", "denied"] create_table "assignments", force: :cascade do |t| - t.string "name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "external_assignment_id" + t.bigint "course_id", null: false t.bigint "course_to_lms_id", null: false + t.datetime "created_at", null: false t.datetime "due_date" - t.datetime "late_due_date" t.boolean "enabled", default: false + t.string "external_assignment_id" + t.datetime "late_due_date" + t.string "name" + t.datetime "updated_at", null: false + t.index ["course_id"], name: "index_assignments_on_course_id" end create_table "blazer_audits", force: :cascade do |t| - t.bigint "user_id" + t.datetime "created_at" + t.string "data_source" t.bigint "query_id" t.text "statement" - t.string "data_source" - t.datetime "created_at" + t.bigint "user_id" t.index ["query_id"], name: "index_blazer_audits_on_query_id" t.index ["user_id"], name: "index_blazer_audits_on_user_id" end create_table "blazer_checks", force: :cascade do |t| + t.string "check_type" + t.datetime "created_at", null: false t.bigint "creator_id" + t.text "emails" + t.datetime "last_run_at" + t.text "message" t.bigint "query_id" - t.string "state" t.string "schedule" - t.text "emails" t.text "slack_channels" - t.string "check_type" - t.text "message" - t.datetime "last_run_at" - t.datetime "created_at", null: false + t.string "state" t.datetime "updated_at", null: false t.index ["creator_id"], name: "index_blazer_checks_on_creator_id" t.index ["query_id"], name: "index_blazer_checks_on_query_id" end create_table "blazer_dashboard_queries", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "dashboard_id" - t.bigint "query_id" t.integer "position" - t.datetime "created_at", null: false + t.bigint "query_id" t.datetime "updated_at", null: false t.index ["dashboard_id"], name: "index_blazer_dashboard_queries_on_dashboard_id" t.index ["query_id"], name: "index_blazer_dashboard_queries_on_query_id" end create_table "blazer_dashboards", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "creator_id" t.string "name" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["creator_id"], name: "index_blazer_dashboards_on_creator_id" end create_table "blazer_queries", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "creator_id" - t.string "name" + t.string "data_source" t.text "description" + t.string "name" t.text "statement" - t.string "data_source" t.string "status" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["creator_id"], name: "index_blazer_queries_on_creator_id" end create_table "course_settings", force: :cascade do |t| - t.bigint "course_id", null: false - t.boolean "enable_extensions", default: false t.integer "auto_approve_days", default: 0 t.integer "auto_approve_extended_request_days", default: 0 - t.integer "max_auto_approve", default: 0 - t.boolean "enable_emails", default: false - t.string "reply_email" + t.bigint "course_id", null: false + t.datetime "created_at", null: false t.string "email_subject", default: "Extension Request Status: {{status}} - {{course_code}}" t.text "email_template", default: "Dear {{student_name}},\n\nYour 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" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "slack_webhook_url" - t.boolean "enable_slack_webhook_url" + t.boolean "enable_emails", default: false + t.boolean "enable_extensions", default: false t.boolean "enable_gradescope", default: false - t.string "gradescope_course_url" - t.boolean "extend_late_due_date", default: true, null: false - t.string "pending_notification_frequency" - t.string "pending_notification_email" t.boolean "enable_min_hours_before_deadline", default: true, null: false + t.boolean "enable_slack_webhook_url" + t.boolean "extend_late_due_date", default: true, null: false + t.string "gradescope_course_url" + t.integer "max_auto_approve", default: 0 t.integer "min_hours_before_deadline", default: 0, null: false - t.index ["course_id"], name: "index_course_settings_on_course_id" + t.string "pending_notification_email" + t.string "pending_notification_frequency" + t.string "reply_email" + t.string "slack_webhook_url" + t.datetime "updated_at", null: false + t.index ["course_id"], name: "index_course_settings_on_course_id", unique: true end create_table "course_to_lmss", force: :cascade do |t| - t.bigint "lms_id" t.bigint "course_id" t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "external_course_id" - t.jsonb "recent_roster_sync", default: {} + t.bigint "lms_id" t.jsonb "recent_assignment_sync", default: {} + t.jsonb "recent_roster_sync", default: {} + t.datetime "updated_at", null: false t.index ["course_id"], name: "index_course_to_lmss_on_course_id" t.index ["lms_id"], name: "index_course_to_lmss_on_lms_id" end create_table "courses", force: :cascade do |t| - t.string "course_name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "canvas_id" t.string "course_code" + t.string "course_name" + t.datetime "created_at", null: false t.string "readonly_api_token" t.string "semester" + t.datetime "updated_at", null: false t.index ["canvas_id"], name: "index_courses_on_canvas_id", unique: true t.index ["readonly_api_token"], name: "index_courses_on_readonly_api_token", unique: true end + create_table "enrollments", force: :cascade do |t| + t.boolean "allow_extended_requests", default: false, null: false + t.bigint "course_id" + t.datetime "created_at", null: false + t.boolean "removed", default: false, null: false + t.string "role" + t.datetime "updated_at", null: false + t.bigint "user_id" + t.index ["course_id"], name: "index_enrollments_on_course_id" + t.index ["user_id"], name: "index_enrollments_on_user_id" + end + create_table "extensions", force: :cascade do |t| t.bigint "assignment_id" - t.string "student_email" + t.datetime "created_at", null: false + t.string "external_extension_id" t.datetime "initial_due_date" - t.datetime "new_due_date" t.bigint "last_processed_by_id" - t.datetime "created_at", null: false + t.datetime "new_due_date" + t.string "student_email" t.datetime "updated_at", null: false - t.string "external_extension_id" t.index ["assignment_id"], name: "index_extensions_on_assignment_id" t.index ["last_processed_by_id"], name: "index_extensions_on_last_processed_by_id" end + create_table "faultline_error_contexts", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "error_occurrence_id", null: false + t.string "key", null: false + t.datetime "updated_at", null: false + t.text "value" + t.index ["error_occurrence_id", "key"], name: "index_faultline_error_contexts_on_error_occurrence_id_and_key" + t.index ["error_occurrence_id"], name: "index_faultline_error_contexts_on_error_occurrence_id" + end + + create_table "faultline_error_groups", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "exception_class", null: false + t.string "file_path" + t.string "fingerprint", null: false + t.datetime "first_seen_at" + t.datetime "last_notified_at" + t.datetime "last_seen_at" + t.integer "line_number" + t.string "method_name" + t.integer "occurrences_count", default: 0 + t.datetime "resolved_at" + t.text "sanitized_message", null: false + t.virtual "searchable", type: :tsvector, as: "to_tsvector('simple'::regconfig, (((((COALESCE(exception_class, ''::character varying))::text || ' '::text) || COALESCE(sanitized_message, ''::text)) || ' '::text) || (COALESCE(file_path, ''::character varying))::text))", stored: true + t.string "status", default: "unresolved" + t.datetime "updated_at", null: false + t.index ["exception_class"], name: "index_faultline_error_groups_on_exception_class" + t.index ["fingerprint"], name: "index_faultline_error_groups_on_fingerprint", unique: true + t.index ["last_seen_at"], name: "index_faultline_error_groups_on_last_seen_at" + t.index ["searchable"], name: "index_faultline_error_groups_on_searchable", using: :gin + t.index ["status"], name: "index_faultline_error_groups_on_status" + end + + create_table "faultline_error_occurrences", force: :cascade do |t| + t.text "backtrace" + t.datetime "created_at", null: false + t.string "environment" + t.bigint "error_group_id", null: false + t.string "exception_class", null: false + t.string "hostname" + t.string "ip_address" + t.json "local_variables" + t.text "message", null: false + t.string "process_id" + t.text "request_headers" + t.string "request_method" + t.text "request_params" + t.string "request_url" + t.string "session_id" + t.datetime "updated_at", null: false + t.string "user_agent" + t.bigint "user_id" + t.string "user_type" + t.index ["created_at"], name: "index_faultline_error_occurrences_on_created_at" + t.index ["error_group_id", "created_at"], name: "idx_on_error_group_id_created_at_98b32c40ac" + t.index ["error_group_id"], name: "index_faultline_error_occurrences_on_error_group_id" + t.index ["user_type", "user_id"], name: "index_faultline_error_occurrences_on_user_type_and_user_id" + end + + create_table "faultline_request_profiles", force: :cascade do |t| + t.datetime "created_at", null: false + t.float "interval_ms" + t.string "mode", default: "cpu" + t.text "profile_data", null: false + t.bigint "request_trace_id", null: false + t.integer "samples", default: 0 + t.index ["request_trace_id"], name: "index_faultline_request_profiles_on_request_trace_id" + end + + create_table "faultline_request_traces", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "db_query_count", default: 0 + t.float "db_runtime_ms" + t.float "duration_ms" + t.string "endpoint", null: false + t.boolean "has_profile", default: false + t.string "http_method", null: false + t.string "path" + t.json "spans" + t.integer "status" + t.float "view_runtime_ms" + t.index ["created_at"], name: "index_faultline_request_traces_on_created_at" + t.index ["endpoint", "created_at"], name: "index_faultline_request_traces_on_endpoint_and_created_at" + t.index ["endpoint"], name: "index_faultline_request_traces_on_endpoint" + end + create_table "form_settings", force: :cascade do |t| t.bigint "course_id", null: false - t.text "reason_desc" - t.text "documentation_desc" - t.enum "documentation_disp", enum_type: "form_display_status" + t.datetime "created_at", null: false t.string "custom_q1" t.text "custom_q1_desc" t.enum "custom_q1_disp", enum_type: "form_display_status" t.string "custom_q2" t.text "custom_q2_desc" t.enum "custom_q2_disp", enum_type: "form_display_status" - t.datetime "created_at", null: false + t.text "documentation_desc" + t.enum "documentation_disp", enum_type: "form_display_status" + t.text "reason_desc" t.datetime "updated_at", null: false t.index ["course_id"], name: "index_form_settings_on_course_id" end create_table "lms_credentials", force: :cascade do |t| - t.bigint "user_id" - t.string "username" - t.string "password" - t.string "token" - t.string "refresh_token" t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "external_user_id" t.datetime "expire_time" + t.string "external_user_id" t.bigint "lms_id" + t.string "password" + t.string "refresh_token" + t.string "token" + t.datetime "updated_at", null: false + t.bigint "user_id" + t.string "username" t.index ["user_id"], name: "index_lms_credentials_on_user_id" end create_table "lmss", force: :cascade do |t| - t.string "lms_name" - t.boolean "use_auth_token" t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "lms_base_url" + t.string "lms_name" + t.datetime "updated_at", null: false + t.boolean "use_auth_token" end create_table "requests", force: :cascade do |t| - t.datetime "requested_due_date" - t.text "reason" - t.text "documentation" + t.bigint "assignment_id", null: false + t.boolean "auto_approved", default: false, null: false + t.bigint "course_id", null: false + t.datetime "created_at", null: false t.text "custom_q1" t.text "custom_q2" + t.text "documentation" t.string "external_extension_id" - t.bigint "course_id", null: false - t.bigint "assignment_id", null: false - t.bigint "user_id", null: false t.bigint "last_processed_by_user_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.text "reason" + t.datetime "requested_due_date" t.enum "status", default: "pending", null: false, enum_type: "request_status" - t.boolean "auto_approved", default: false, null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false t.index ["assignment_id"], name: "index_requests_on_assignment_id" t.index ["auto_approved"], name: "index_requests_on_auto_approved" t.index ["course_id"], name: "index_requests_on_course_id" @@ -210,36 +309,30 @@ t.index ["user_id"], name: "index_requests_on_user_id" end - create_table "user_to_courses", force: :cascade do |t| - t.bigint "user_id" - t.bigint "course_id" - t.string "role" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.boolean "removed", default: false, null: false - t.boolean "allow_extended_requests", default: false, null: false - t.index ["course_id"], name: "index_user_to_courses_on_course_id" - t.index ["user_id"], name: "index_user_to_courses_on_user_id" - end - create_table "users", force: :cascade do |t| - t.string "email" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.boolean "admin", default: false t.string "canvas_uid" + t.datetime "created_at", null: false + t.string "email" t.string "name" t.string "student_id" - t.boolean "admin", default: false + t.datetime "updated_at", null: false t.index ["canvas_uid"], name: "index_users_on_canvas_uid", unique: true t.index ["email"], name: "index_users_on_email", unique: true end add_foreign_key "assignments", "course_to_lmss" + add_foreign_key "assignments", "courses" add_foreign_key "course_settings", "courses" add_foreign_key "course_to_lmss", "courses" add_foreign_key "course_to_lmss", "lmss" + add_foreign_key "enrollments", "courses" + add_foreign_key "enrollments", "users" add_foreign_key "extensions", "assignments" add_foreign_key "extensions", "users", column: "last_processed_by_id" + add_foreign_key "faultline_error_contexts", "faultline_error_occurrences", column: "error_occurrence_id" + add_foreign_key "faultline_error_occurrences", "faultline_error_groups", column: "error_group_id" + add_foreign_key "faultline_request_profiles", "faultline_request_traces", column: "request_trace_id", on_delete: :cascade add_foreign_key "form_settings", "courses" add_foreign_key "lms_credentials", "lmss" add_foreign_key "lms_credentials", "users" @@ -247,6 +340,4 @@ add_foreign_key "requests", "courses" add_foreign_key "requests", "users" add_foreign_key "requests", "users", column: "last_processed_by_user_id" - add_foreign_key "user_to_courses", "courses" - add_foreign_key "user_to_courses", "users" end diff --git a/db/seeds.rb b/db/seeds.rb index 6457cbea..520af0e5 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -40,19 +40,17 @@ end # Enroll teacher as instructor - UserToCourse.find_or_create_by!(user_id: teacher_user.id, course_id: test_course.id) do |utc| - utc.role = 'teacher' + Enrollment.find_or_create_by!(user_id: teacher_user.id, course_id: test_course.id) do |enrollment| + enrollment.role = 'teacher' end # Enroll student as student - UserToCourse.find_or_create_by!(user_id: student_user.id, course_id: test_course.id) do |utc| - utc.role = 'student' + Enrollment.find_or_create_by!(user_id: student_user.id, course_id: test_course.id) do |enrollment| + enrollment.role = 'student' end - # Enable extensions for test course - CourseSettings.find_or_create_by!(course_id: test_course.id) do |cs| - cs.enable_extensions = true - end + # Enable extensions for test course (settings are created with the course) + test_course.course_settings.update!(enable_extensions: true) # Create form settings for test course FormSetting.find_or_create_by!(course_id: test_course.id) do |fs| diff --git a/features/step_definitions/custom_steps.rb b/features/step_definitions/custom_steps.rb index 651f5c7a..1bc6ff5d 100644 --- a/features/step_definitions/custom_steps.rb +++ b/features/step_definitions/custom_steps.rb @@ -3,14 +3,14 @@ @course = create(:course, course_name: 'Physics 110A', canvas_id: 'Phys110A', course_code: 'PHYS110A') teacher = create(:teacher, email: 'user1@berkeley.edu', canvas_uid: 'canvas_uid_1') - create(:user_to_course, user: teacher, course: @course, role: 'teacher') + create(:enrollment, user: teacher, course: @course, role: 'teacher') ta = create(:ta, email: 'user2@berkeley.edu', canvas_uid: 'canvas_uid_2') - create(:user_to_course, user: ta, course: @course, role: 'ta') + create(:enrollment, user: ta, course: @course, role: 'ta') 3.times do |i| student = create(:student, email: "user#{i + 3}@berkeley.edu", canvas_uid: "canvas_uid_#{i + 3}") - create(:user_to_course, user: student, course: @course, role: 'student') + create(:enrollment, user: student, course: @course, role: 'student') end end @@ -133,7 +133,7 @@ # Then the enrollment for "User 3" should allow/disallow extended requests Then(/^the enrollment for "([^"]*)" should (allow|disallow) extended requests$/) do |user_name, state| user = User.find_by!(name: user_name) - enrollment = UserToCourse.find_by!(user: user, course: @course, role: 'student') + enrollment = Enrollment.find_by!(user: user, course: @course, role: 'student') expected = (state == 'allow') expect(enrollment.reload.allow_extended_requests).to eq(expected) end @@ -142,7 +142,7 @@ # Given the enrollment for "User 3" allows extended requests Given(/^the enrollment for "([^"]*)" allows extended requests$/) do |user_name| user = User.find_by!(name: user_name) - enrollment = UserToCourse.find_by!(user: user, course: @course, role: 'student') + enrollment = Enrollment.find_by!(user: user, course: @course, role: 'student') enrollment.update!(allow_extended_requests: true) end @@ -196,7 +196,7 @@ end Given(/^a pending request exists$/) do - student = User.joins(:user_to_courses).find_by(user_to_courses: { course: @course, role: 'student' }) + student = User.joins(:enrollments).find_by(enrollments: { course: @course, role: 'student' }) assignment = Assignment.first @pending_request = create(:request, user: student, course: @course, assignment: assignment) end diff --git a/lib/tasks/backfill_extend_late_due_date.rake b/lib/tasks/backfill_extend_late_due_date.rake deleted file mode 100644 index 098d4b0c..00000000 --- a/lib/tasks/backfill_extend_late_due_date.rake +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -namespace :course_settings do - desc 'Backfill extend_late_due_date setting for all existing courses (sets to true for all courses)' - task backfill_extend_late_due_date: :environment do - puts 'Starting backfill of extend_late_due_date setting for all courses...' - - total_courses = Course.count - updated_count = 0 - skipped_count = 0 - created_count = 0 - - Course.find_each do |course| - if course.course_settings.nil? - # Create course settings if they don't exist - CourseSettings.create!(course: course, extend_late_due_date: true) - created_count += 1 - puts "Created course settings for course #{course.id} (#{course.course_name})" - elsif course.course_settings.extend_late_due_date.nil? - # Update existing course settings if extend_late_due_date is nil - course.course_settings.update!(extend_late_due_date: true) - updated_count += 1 - puts "Updated course #{course.id} (#{course.course_name})" - else - skipped_count += 1 - end - end - - puts "\nBackfill complete!" - puts "Total courses: #{total_courses}" - puts "Settings created: #{created_count}" - puts "Settings updated: #{updated_count}" - puts "Skipped (already set): #{skipped_count}" - end -end diff --git a/lib/tasks/lms_credentials.rake b/lib/tasks/lms_credentials.rake new file mode 100644 index 00000000..be4b131a --- /dev/null +++ b/lib/tasks/lms_credentials.rake @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +namespace :lms_credentials do + desc 'Delete stored LMS credentials (default: canvas) so users re-authenticate. ' \ + 'Run after changing the Canvas developer key or its scopes, which invalidates ' \ + 'every token derived from the key. Usage: bin/rails "lms_credentials:purge[canvas]"' + task :purge, [ :lms_name ] => :environment do |_task, args| + lms_name = args[:lms_name] || 'canvas' + count = LmsCredential.joins(:lms).where('LOWER(lmss.lms_name) = ?', lms_name.downcase).delete_all + puts "Deleted #{count} #{lms_name} credential(s). " \ + 'Users will re-authenticate automatically on their next visit; auto-approval in each ' \ + 'course resumes once one of its staff members has logged in.' + end +end diff --git a/public/400.html b/public/400.html new file mode 100644 index 00000000..282dbc8c --- /dev/null +++ b/public/400.html @@ -0,0 +1,114 @@ + + + + + + + The server cannot process the request due to a client error (400 Bad Request) + + + + + + + + + + + + + +
+
+ +
+
+

The server cannot process the request due to a client error. Please check the request and try again. If you’re the application owner check the logs for more information.

+
+
+ + + + diff --git a/spec/controllers/api/v1/assignments_controller_spec.rb b/spec/controllers/api/v1/assignments_controller_spec.rb index 58a0f11e..5c8446e7 100644 --- a/spec/controllers/api/v1/assignments_controller_spec.rb +++ b/spec/controllers/api/v1/assignments_controller_spec.rb @@ -24,7 +24,7 @@ def json_response Extension.destroy_all Assignment.destroy_all CourseToLms.destroy_all - UserToCourse.destroy_all + Enrollment.destroy_all Course.destroy_all Lms.destroy_all User.destroy_all @@ -42,7 +42,7 @@ def json_response it 'returns status :bad_request when name is missing' do post :create, params: valid_params.except(:name) expect(response).to have_http_status(:bad_request) - expect(json_response['error']).to include('param is missing or the value is empty: name') + expect(json_response['error']).to include('param is missing or the value is empty or invalid: name') end end diff --git a/spec/controllers/api/v1/lmss_controller_spec.rb b/spec/controllers/api/v1/lmss_controller_spec.rb index f662384d..969daca7 100644 --- a/spec/controllers/api/v1/lmss_controller_spec.rb +++ b/spec/controllers/api/v1/lmss_controller_spec.rb @@ -18,7 +18,7 @@ def json_response Extension.destroy_all Assignment.destroy_all CourseToLms.destroy_all - UserToCourse.destroy_all + Enrollment.destroy_all Course.destroy_all Lms.destroy_all User.destroy_all @@ -29,7 +29,7 @@ def json_response it 'returns status :bad_request' do post :create, params: { course_id: @course.id, external_course_id: @external_course_id } expect(response).to have_http_status(:bad_request) - expect(response.body).to include('param is missing or the value is empty: lms_id') + expect(response.body).to include('param is missing or the value is empty or invalid: lms_id') end end diff --git a/spec/controllers/assignments_controller_spec.rb b/spec/controllers/assignments_controller_spec.rb index 672b19d2..d82841e3 100644 --- a/spec/controllers/assignments_controller_spec.rb +++ b/spec/controllers/assignments_controller_spec.rb @@ -10,7 +10,7 @@ let!(:user) { User.create!(name: 'Test User', email: 'test@example.com', canvas_uid: '123') } let!(:course) { Course.create!(course_name: 'Test Course', canvas_id: '123') } let!(:course_to_lms) { CourseToLms.create!(course: course, lms_id: 1, external_course_id: '123') } - let!(:course_settings) { CourseSettings.create!(course: course, enable_extensions: true) } + let!(:course_settings) { course.course_settings.tap { |cs| cs.update!(enable_extensions: true) } } let!(:assignment) do Assignment.create!( name: 'Test Assignment', @@ -23,7 +23,7 @@ context 'when the user is an instructor' do before do - UserToCourse.create!(user: user, course: course, role: UserToCourse::TEACHER_ROLE) + Enrollment.create!(user: user, course: course, role: Enrollment::TEACHER_ROLE) end it 'updates the enabled status to true' do @@ -45,7 +45,7 @@ context 'when the user is not an instructor' do before do - UserToCourse.create!(user: user, course: course, role: UserToCourse::STUDENT_ROLE) + Enrollment.create!(user: user, course: course, role: Enrollment::STUDENT_ROLE) end it 'returns a forbidden status' do @@ -58,7 +58,7 @@ context 'when a student forges an instructor role in the request body' do before do - UserToCourse.create!(user: user, course: course, role: UserToCourse::STUDENT_ROLE) + Enrollment.create!(user: user, course: course, role: Enrollment::STUDENT_ROLE) end it 'ignores the client-supplied role and returns forbidden' do @@ -72,7 +72,7 @@ context 'when course-level extensions are disabled' do before do course_settings.update!(enable_extensions: false) - UserToCourse.create!(user: user, course: course, role: UserToCourse::TEACHER_ROLE) + Enrollment.create!(user: user, course: course, role: Enrollment::TEACHER_ROLE) end it 'still allows enabling the assignment and returns ok status' do @@ -85,7 +85,7 @@ context 'when there is no due_date on an Assignment' do before do - UserToCourse.create!(user: user, course: course, role: UserToCourse::TEACHER_ROLE) + Enrollment.create!(user: user, course: course, role: Enrollment::TEACHER_ROLE) assignment.update!(due_date: nil) end diff --git a/spec/controllers/concerns/token_refreshable_spec.rb b/spec/controllers/concerns/token_refreshable_spec.rb index 4a7f308a..68376ffd 100644 --- a/spec/controllers/concerns/token_refreshable_spec.rb +++ b/spec/controllers/concerns/token_refreshable_spec.rb @@ -19,6 +19,7 @@ def current_user end end + let(:expire_time) { 1.hour.from_now } let(:user) do User.create!(email: 'test@example.com', canvas_uid: '123').tap do |u| Lms.find_or_create_by(id: 1) { |l| l.lms_name = 'Canvas'; l.use_auth_token = true } @@ -26,24 +27,12 @@ def current_user lms_id: 1, token: 'valid_token', refresh_token: 'refresh_token', - expire_time: 10.minutes.from_now + expire_time: expire_time ) end end before do - stub_request(:post, "#{ENV.fetch('CANVAS_URL', nil)}/login/oauth2/token") - .with( - body: { 'grant_type' => 'refresh_token', 'refresh_token' => 'refresh_token' }, - headers: { - 'Accept' => '*/*', - 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', - 'Authorization' => 'Basic MjY1MzAwMDAwMDAwMDAwMDA0OkxZazZLd0xEQXkyTmtQNk1hTDdQZkRES0MzeFRQeTZ1a21EY1d4TkFyeXYzMjhDck5HQXR0VnVaaDl4Unl5Tmg=', - 'Content-Type' => 'application/x-www-form-urlencoded', - 'User-Agent' => 'Faraday v2.12.2' - } - ) - .to_return(status: 200, body: '', headers: {}) routes.draw { get 'dummy_action' => 'anonymous#dummy_action' } session[:user_id] = user.id end @@ -51,19 +40,15 @@ def current_user describe '#with_valid_token' do context 'when token is not expiring soon' do it 'yields with current token' do - user_double = instance_double(User, lms_credentials: user.lms_credentials, token_expires_soon?: false) - - allow(controller).to receive(:current_user).and_return(user_double) - get :dummy_action expect(response.body).to eq('Token: valid_token') end end context 'when token is expiring soon and refresh succeeds' do - it 'refreshes token and yields with new token' do - allow(user).to receive(:token_expires_soon?).and_return(true) + let(:expire_time) { 5.minutes.from_now } + it 'refreshes token and yields with new token' do new_token = instance_double(OAuth2::AccessToken, token: 'refreshed_token', refresh_token: 'new_refresh', @@ -79,14 +64,22 @@ def current_user end context 'when token is expiring soon and refresh fails' do - it 'raises an error and logs it' do - allow(user).to receive(:token_expires_soon?).and_return(true) + let(:expire_time) { 5.minutes.from_now } + it 'raises an error and logs it' do fake_response = instance_double(OAuth2::Response, parsed: {}, status: 401) allow(OAuth2::AccessToken).to receive(:from_hash).and_raise(OAuth2::Error.new(fake_response)) expect { get :dummy_action }.to raise_error('Invalid authentication token') end end + + context 'when the user has no Canvas credentials' do + it 'raises an error' do + user.lms_credentials.destroy_all + + expect { get :dummy_action }.to raise_error('Invalid authentication token') + end + end end end diff --git a/spec/controllers/course_settings_controller_spec.rb b/spec/controllers/course_settings_controller_spec.rb index 55eeb38f..63019ce1 100644 --- a/spec/controllers/course_settings_controller_spec.rb +++ b/spec/controllers/course_settings_controller_spec.rb @@ -18,13 +18,13 @@ describe 'instructor access' do before do session[:user_id] = instructor.canvas_uid - UserToCourse.create!(user: instructor, course: course, role: 'instructor') + Enrollment.create!(user: instructor, course: course, role: 'teacher') allow_any_instance_of(Course).to receive(:user_role).with(instructor).and_return('instructor') end describe 'POST #update' do - it 'creates new course settings when none exist' do - expect(CourseSettings.where(course_id: course.id).count).to eq(0) + it 'updates the settings created automatically with the course' do + expect(CourseSettings.where(course_id: course.id).count).to eq(1) post :update, params: { course_id: course.id, @@ -37,7 +37,6 @@ tab: 'general' } - # Now verify a new settings record was created expect(CourseSettings.where(course_id: course.id).count).to eq(1) expect(response).to redirect_to(course_settings_path(course.id, tab: 'general')) expect(flash[:notice]).to eq('Course settings updated successfully.') @@ -47,9 +46,8 @@ end it 'updates existing course settings' do - # Create existing settings - course_settings = CourseSettings.create!( - course: course, + course_settings = course.course_settings + course_settings.update!( enable_extensions: false, auto_approve_days: 1, auto_approve_extended_request_days: 2, @@ -79,13 +77,10 @@ end it 'handles update failures gracefully' do - CourseSettings.create!( - course: course, + course.course_settings.update!( enable_extensions: false, auto_approve_days: 1 ) - - expect(CourseSettings.where(course_id: course.id).count).to eq(1) allow_any_instance_of(CourseSettings).to receive(:update).and_return(false) post :update, params: { @@ -99,8 +94,7 @@ end it 'resets email templates and redirects' do - CourseSettings.create!( - course: course, + course.course_settings.update!( enable_extensions: true, email_subject: 'Custom Subject', email_template: 'Custom Template' @@ -122,9 +116,8 @@ 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) + Enrollment.create!(user: instructor, course: course, role: 'teacher') + course.course_settings.update!(enable_extensions: true) end it 'persists pending notification settings' do @@ -204,14 +197,11 @@ before do session[:user_id] = instructor.canvas_uid - UserToCourse.create!(user: instructor, course: course, role: 'instructor') + Enrollment.create!(user: instructor, course: course, role: 'teacher') allow_any_instance_of(Course).to receive(:user_role).with(instructor).and_return('instructor') - # Create settings to enable extensions - CourseSettings.create!( - course: course, - enable_extensions: true - ) + # Enable extensions on the course's settings + course.course_settings.update!(enable_extensions: true) # Clear instance variables before each test controller.instance_variable_set(:@pending_requests_count, nil) @@ -265,12 +255,11 @@ refresh_token: 'student_refresh_token', expire_time: 1.hour.from_now ) - UserToCourse.create!(user: student, course: course, role: 'student') + Enrollment.create!(user: student, course: course, role: 'student') allow_any_instance_of(Course).to receive(:user_role).with(student).and_return('student') - # Create some course settings to attempt to modify - CourseSettings.create!( - course: course, + # Configure course settings to attempt to modify + course.course_settings.update!( enable_extensions: false, auto_approve_days: 1 ) @@ -322,7 +311,7 @@ it 'redirects to courses path when course is not found' do session[:user_id] = instructor.canvas_uid - UserToCourse.create!(user: instructor, course: course, role: 'instructor') + Enrollment.create!(user: instructor, course: course, role: 'teacher') post :update, params: { course_id: 999, diff --git a/spec/controllers/courses_controller_spec.rb b/spec/controllers/courses_controller_spec.rb index 9136d9cf..4344c1f2 100644 --- a/spec/controllers/courses_controller_spec.rb +++ b/spec/controllers/courses_controller_spec.rb @@ -5,12 +5,12 @@ let(:course) { Course.create!(course_name: 'Test Course', canvas_id: '456', course_code: 'TST101') } let(:course_to_lms) { CourseToLms.create!(course: course, external_course_id: '456', lms_id: 1) } let(:student_course) { Course.create!(course_name: 'Student Course', canvas_id: '789', course_code: 'STU101') } - let(:course_settings) { CourseSettings.create!(course: course, enable_extensions: true) } + let(:course_settings) { course.course_settings.tap { |cs| cs.update!(enable_extensions: true) } } before do Lms.find_or_create_by(id: 1) { |l| l.lms_name = 'Canvas'; l.use_auth_token = true } session[:user_id] = user.canvas_uid - UserToCourse.create!(user: user, course: course, role: 'student') + Enrollment.create!(user: user, course: course, role: 'student') user.lms_credentials.create!( lms_id: 1, token: 'fake_token', @@ -31,7 +31,7 @@ end it 'includes Lead TA enrollments in staff courses' do - UserToCourse.create!(user: user, course: student_course, role: 'leadta') + Enrollment.create!(user: user, course: student_course, role: 'leadta') get :index @@ -43,8 +43,8 @@ let(:fall_course) { Course.create!(course_name: 'Fall Course', canvas_id: 'fa1', course_code: 'FA101', semester: 'Fall 2025') } before do - UserToCourse.create!(user: user, course: spring_course, role: 'teacher') - UserToCourse.create!(user: user, course: fall_course, role: 'teacher') + Enrollment.create!(user: user, course: spring_course, role: 'teacher') + Enrollment.create!(user: user, course: fall_course, role: 'teacher') end it 'groups teacher courses by semester, most-recent-first' do @@ -56,15 +56,13 @@ end it 'groups student courses by semester, most-recent-first' do - # Disable extensions on the default course so it doesn't appear - CourseSettings.create!(course: course, enable_extensions: false) - + # Extensions stay disabled on the default course so it doesn't appear spring_student = Course.create!(course_name: 'Student Spring', canvas_id: 'ss1', course_code: 'SS101', semester: 'Spring 2026') fall_student = Course.create!(course_name: 'Student Fall', canvas_id: 'sf1', course_code: 'SF101', semester: 'Fall 2025') - CourseSettings.create!(course: spring_student, enable_extensions: true) - CourseSettings.create!(course: fall_student, enable_extensions: true) - UserToCourse.create!(user: user, course: spring_student, role: 'student') - UserToCourse.create!(user: user, course: fall_student, role: 'student') + spring_student.course_settings.update!(enable_extensions: true) + fall_student.course_settings.update!(enable_extensions: true) + Enrollment.create!(user: user, course: spring_student, role: 'student') + Enrollment.create!(user: user, course: fall_student, role: 'student') get :index @@ -97,7 +95,7 @@ let!(:course_to_lms_record) { CourseToLms.create!(course: course, external_course_id: '456', lms_id: 1) } before do - UserToCourse.create!(user: user, course: course, role: 'teacher') + Enrollment.create!(user: user, course: course, role: 'teacher') end it 'renders the instructor show template' do @@ -202,7 +200,7 @@ context 'when user is a teacher (course admin)' do before do - UserToCourse.create!(user: user, course: course, role: 'teacher') + Enrollment.create!(user: user, course: course, role: 'teacher') end it 'syncs enrollments and returns OK' do @@ -217,7 +215,7 @@ context 'when user is a leadta (course admin)' do before do - UserToCourse.create!(user: user, course: course, role: 'leadta') + Enrollment.create!(user: user, course: course, role: 'leadta') end it 'syncs enrollments and returns OK' do @@ -232,7 +230,7 @@ context 'when user is a TA (staff but not course admin)' do before do - UserToCourse.create!(user: user, course: course, role: 'ta') + Enrollment.create!(user: user, course: course, role: 'ta') end it 'syncs enrollments and returns OK' do @@ -401,7 +399,7 @@ context 'when user is a teacher (course admin)' do before do - UserToCourse.create!(user: user, course: course, role: 'teacher') + Enrollment.create!(user: user, course: course, role: 'teacher') end it 'renders the enrollments view successfully' do @@ -424,7 +422,7 @@ context 'when user is a TA (staff but not course admin)' do before do - UserToCourse.create!(user: user, course: course, role: 'ta') + Enrollment.create!(user: user, course: course, role: 'ta') end it 'renders the enrollments view successfully' do @@ -461,9 +459,8 @@ before do Extension.create!(assignment: assignment, student_email: user.email) - UserToCourse.create!(user: user, course: course, role: 'teacher') + Enrollment.create!(user: user, course: course, role: 'teacher') Request.create!(course: course, assignment: assignment, user: user, requested_due_date: Time.current, reason: 'Reason') - CourseSettings.create!(course: course) FormSetting.create!( course: course, documentation_disp: 'required', @@ -479,7 +476,7 @@ .and change(Assignment, :count).by(-1) .and change(Extension, :count).by(-1) .and change(CourseToLms, :count).by(-1) - .and change(UserToCourse, :count).by(-2) + .and change(Enrollment, :count).by(-2) .and change(Request, :count).by(-1) .and change(CourseSettings, :count).by(-1) .and change(FormSetting, diff --git a/spec/controllers/user_to_courses_controller_spec.rb b/spec/controllers/enrollments_controller_spec.rb similarity index 89% rename from spec/controllers/user_to_courses_controller_spec.rb rename to spec/controllers/enrollments_controller_spec.rb index 90c53c2f..0c16cb18 100644 --- a/spec/controllers/user_to_courses_controller_spec.rb +++ b/spec/controllers/enrollments_controller_spec.rb @@ -1,16 +1,16 @@ require 'rails_helper' -RSpec.describe UserToCoursesController, type: :controller do +RSpec.describe EnrollmentsController, type: :controller do let(:instructor) { User.create!(email: 'instructor@example.com', canvas_uid: '100', name: 'Instructor') } let(:student_user) { User.create!(email: 'student@example.com', canvas_uid: '200', name: 'Student') } let(:course) { Course.create!(course_name: 'Test Course', canvas_id: '456', course_code: 'TST101') } - let(:student_enrollment) { UserToCourse.create!(user: student_user, course: course, role: 'student') } + let(:student_enrollment) { Enrollment.create!(user: student_user, course: course, role: 'student') } describe 'PATCH #toggle_allow_extended_requests' do context 'when user is an instructor' do before do Lms.find_or_create_by(id: 1) { |l| l.lms_name = 'Canvas'; l.use_auth_token = true } - UserToCourse.create!(user: instructor, course: course, role: 'teacher') + Enrollment.create!(user: instructor, course: course, role: 'teacher') student_enrollment session[:user_id] = instructor.canvas_uid instructor.lms_credentials.create!( @@ -50,8 +50,8 @@ it 'returns unprocessable_entity when update fails' do errors = ActiveModel::Errors.new(student_enrollment) errors.add(:base, 'Validation failed') - allow_any_instance_of(UserToCourse).to receive(:update).and_return(false) - allow_any_instance_of(UserToCourse).to receive(:errors).and_return(errors) + allow_any_instance_of(Enrollment).to receive(:update).and_return(false) + allow_any_instance_of(Enrollment).to receive(:errors).and_return(errors) patch :toggle_allow_extended_requests, params: { course_id: course.id, @@ -59,7 +59,7 @@ allow_extended_requests: true } - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) expect(response.parsed_body['redirect_to']).to be_present end end @@ -127,7 +127,7 @@ context 'when enrollment does not exist' do before do Lms.find_or_create_by(id: 1) { |l| l.lms_name = 'Canvas'; l.use_auth_token = true } - UserToCourse.create!(user: instructor, course: course, role: 'teacher') + Enrollment.create!(user: instructor, course: course, role: 'teacher') session[:user_id] = instructor.canvas_uid instructor.lms_credentials.create!( lms_id: 1, diff --git a/spec/controllers/form_settings_controller_spec.rb b/spec/controllers/form_settings_controller_spec.rb index 3850da40..2e42e7ad 100644 --- a/spec/controllers/form_settings_controller_spec.rb +++ b/spec/controllers/form_settings_controller_spec.rb @@ -3,7 +3,7 @@ RSpec.describe FormSettingsController, type: :controller do let(:user) { User.create!(email: 'instructor@example.com', canvas_uid: '12345', name: 'Instructor') } let(:course) { Course.create!(course_name: 'Algorithms', canvas_id: '789', course_code: 'CS101') } - let(:user_to_course) { UserToCourse.create!(user: user, course: course, role: 'teacher') } + let(:enrollment) { Enrollment.create!(user: user, course: course, role: 'teacher') } let(:valid_params) do { course_id: course.id, @@ -24,6 +24,7 @@ before do session[:user_id] = user.canvas_uid allow_any_instance_of(Course).to receive(:user_role).and_return('instructor') + allow_any_instance_of(Course).to receive(:course_staff?).and_return(true) course.create_form_setting!( documentation_disp: 'hidden', custom_q1_disp: 'optional', @@ -116,6 +117,7 @@ context 'when user is a student' do before do allow_any_instance_of(Course).to receive(:user_role).and_return('student') + allow_any_instance_of(Course).to receive(:course_staff?).and_return(false) end it 'denies access and redirects to courses path' do diff --git a/spec/controllers/requests_controller_spec.rb b/spec/controllers/requests_controller_spec.rb index 61f06e53..9482df5e 100644 --- a/spec/controllers/requests_controller_spec.rb +++ b/spec/controllers/requests_controller_spec.rb @@ -25,7 +25,7 @@ custom_q1_disp: 'hidden', custom_q2_disp: 'hidden' ) - UserToCourse.create!(user: user, course: course, role: 'student') + Enrollment.create!(user: user, course: course, role: 'student') CourseToLms.create!(course:, lms_id: 1) user.lms_credentials.create!( @@ -44,7 +44,7 @@ it 'renders instructor request index' do session[:user_id] = instructor.canvas_uid - UserToCourse.create!(user: instructor, course: teacher_course, role: 'teacher') # or use 'ta' + Enrollment.create!(user: instructor, course: teacher_course, role: 'teacher') # or use 'ta' FormSetting.create!(course: teacher_course, documentation_disp: 'hidden', custom_q1_disp: 'hidden', custom_q2_disp: 'hidden') get :index, params: { course_id: teacher_course.id } @@ -54,7 +54,7 @@ it 'assigns @search_query from params[:search]' do session[:user_id] = instructor.canvas_uid - UserToCourse.create!(user: instructor, course: teacher_course, role: 'teacher') + Enrollment.create!(user: instructor, course: teacher_course, role: 'teacher') FormSetting.create!(course: teacher_course, documentation_disp: 'hidden', custom_q1_disp: 'hidden', custom_q2_disp: 'hidden') get :index, params: { course_id: teacher_course.id, search: '12345', show_all: 'true' } @@ -64,7 +64,7 @@ it 'assigns @search_query as nil when no search param is provided' do session[:user_id] = instructor.canvas_uid - UserToCourse.create!(user: instructor, course: teacher_course, role: 'teacher') + Enrollment.create!(user: instructor, course: teacher_course, role: 'teacher') FormSetting.create!(course: teacher_course, documentation_disp: 'hidden', custom_q1_disp: 'hidden', custom_q2_disp: 'hidden') get :index, params: { course_id: teacher_course.id } @@ -85,6 +85,25 @@ get :new, params: { course_id: course.id } expect(response).to render_template('requests/new') end + + it 'renders the on-behalf-of form for staff' do + session[:user_id] = instructor.canvas_uid + Enrollment.create!(user: instructor, course: course, role: 'teacher') + + get :new, params: { course_id: course.id } + + expect(response).to render_template('requests/new_for_student') + end + + it 'redirects a user with no role in the course' do + other = User.create!(email: 'norole@example.com', canvas_uid: '654', name: 'No Role') + session[:user_id] = other.canvas_uid + + get :new, params: { course_id: course.id } + + expect(response).to redirect_to(course_path(course)) + expect(flash[:alert]).to eq('You do not have access to this page.') + end end describe 'POST #create' do @@ -292,12 +311,36 @@ expect(response).to redirect_to(course_request_path(course, request)) expect(flash[:notice]).to include('updated') end + + it 'does not reassign the request to a different assignment' do + other_assignment = Assignment.create!( + name: 'A2', + course_to_lms_id: course_to_lms.id, + due_date: 5.days.from_now, + external_assignment_id: 'x2', + enabled: true + ) + + patch :update, params: { + course_id: course.id, + id: request.id, + request: { + assignment_id: other_assignment.id, + reason: 'Updated reason', + requested_due_date: Date.tomorrow.to_s, + due_time: '12:00' + } + } + + expect(response).to redirect_to(course_request_path(course, request)) + expect(request.reload.assignment_id).to eq(assignment.id) + end end describe 'POST #cancel' do before do session[:user_id] = user.canvas_uid - UserToCourse.create!(user: user, course: course, role: 'student') + Enrollment.create!(user: user, course: course, role: 'student') end it 'cancels the request and updates its status to denied' do @@ -329,7 +372,7 @@ describe 'POST #approve' do before do session[:user_id] = instructor.canvas_uid - UserToCourse.create!(user: instructor, course: course, role: 'teacher') + Enrollment.create!(user: instructor, course: course, role: 'teacher') instructor.lms_credentials.create!( lms_id: 1, token: 'instructor_token', @@ -367,7 +410,7 @@ end it 'approves a pending request' do - allow(request).to receive(:approve).and_return(true) + allow_any_instance_of(Request).to receive(:approve).and_return(true) post :approve, params: { course_id: course.id, id: request.id } @@ -392,7 +435,7 @@ post :approve, params: { course_id: course.id, id: request.id }, format: :json payload = response.parsed_body - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) expect(payload['success']).to be(false) expect(payload['message']).to match(/failed/i) end @@ -401,7 +444,7 @@ describe 'POST #reject' do before do session[:user_id] = instructor.canvas_uid - UserToCourse.create!(user: instructor, course: course, role: 'teacher') + Enrollment.create!(user: instructor, course: course, role: 'teacher') FormSetting.create!(course: course, documentation_disp: 'hidden', custom_q1_disp: 'hidden', custom_q2_disp: 'hidden') end @@ -435,7 +478,7 @@ post :reject, params: { course_id: course.id, id: request.id }, format: :json payload = response.parsed_body - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) expect(payload['success']).to be(false) expect(payload['message']).to match(/failed/i) end @@ -466,7 +509,7 @@ before do Lms.find_or_create_by(id: 1) { |l| l.lms_name = 'Canvas'; l.use_auth_token = true } session[:user_id] = instructor.canvas_uid - UserToCourse.create!(user: instructor, course: course, role: 'teacher') + Enrollment.create!(user: instructor, course: course, role: 'teacher') instructor.lms_credentials.create!( lms_id: 1, token: 'instructor_token', @@ -502,7 +545,7 @@ post :mass_approve, params: { course_id: course.id, request_ids: [] }, format: :json payload = response.parsed_body - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) expect(payload['success']).to be(false) expect(payload['message']).to match(/select at least one/i) end @@ -532,7 +575,7 @@ before do session[:user_id] = instructor.canvas_uid - UserToCourse.create!(user: instructor, course: course, role: 'teacher') + Enrollment.create!(user: instructor, course: course, role: 'teacher') end it 'rejects selected requests and returns processed IDs' do @@ -560,7 +603,7 @@ }, format: :json payload = response.parsed_body - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) expect(payload['success']).to be(false) expect(payload['message']).to match(/no pending requests/i) end @@ -573,11 +616,10 @@ before do session[:user_id] = user.canvas_uid - UserToCourse.create!(user: user, course: course, role: 'student') + Enrollment.create!(user: user, course: course, role: 'student') - # Create course settings for auto-approval - CourseSettings.create!( - course: course, + # Configure course settings for auto-approval + course.course_settings.update!( enable_extensions: true, auto_approve_days: 3, max_auto_approve: 5 @@ -676,11 +718,10 @@ before do session[:user_id] = user.canvas_uid - UserToCourse.create!(user: user, course: course, role: 'student') + Enrollment.create!(user: user, course: course, role: 'student') - # Create course settings for auto-approval - CourseSettings.create!( - course: course, + # Configure course settings for auto-approval + course.course_settings.update!( enable_extensions: true, auto_approve_days: 3, max_auto_approve: 5 @@ -814,7 +855,7 @@ Request.create!(user: other_student, course: course, assignment: assignment, reason: 'Theirs', requested_due_date: 3.days.from_now) end - before { UserToCourse.create!(user: other_student, course: course, role: 'student') } + before { Enrollment.create!(user: other_student, course: course, role: 'student') } it 'is not viewable via #show' do get :show, params: { course_id: course.id, id: others_request.id } @@ -850,7 +891,7 @@ before do session[:user_id] = instructor.canvas_uid - UserToCourse.create!(user: instructor, course: course, role: 'teacher') + Enrollment.create!(user: instructor, course: course, role: 'teacher') end it 'rejects filing on behalf of a student who is not enrolled in the course' do @@ -887,7 +928,7 @@ end it 'creates a request for an enrolled student' do - UserToCourse.create!(user: enrolled_student, course: course, role: 'student') + Enrollment.create!(user: enrolled_student, course: course, role: 'student') post :create_for_student, params: { course_id: course.id, @@ -904,7 +945,7 @@ end it 'treats an assignment from another course as an invalid request' do - UserToCourse.create!(user: enrolled_student, course: course, role: 'student') + Enrollment.create!(user: enrolled_student, course: course, role: 'student') post :create_for_student, params: { course_id: course.id, @@ -923,4 +964,71 @@ end end end + + describe 'staff-only actions' do + before do + Enrollment.create!(user: user, course: course, role: 'student') + end + + it 'forbids a student from approving a request' do + post :approve, params: { course_id: course.id, id: request.id } + + expect(response).to redirect_to(courses_path) + expect(flash[:alert]).to include('do not have access') + expect(request.reload.status).to eq('pending') + end + + it 'forbids a student from rejecting a request' do + post :reject, params: { course_id: course.id, id: request.id } + + expect(response).to redirect_to(courses_path) + expect(flash[:alert]).to include('do not have access') + expect(request.reload.status).to eq('pending') + end + + it 'forbids a student from mass-approving' do + post :mass_approve, params: { course_id: course.id, request_ids: [ request.id ] } + + expect(response).to redirect_to(courses_path) + expect(flash[:alert]).to include('do not have access') + end + + it 'forbids a student from filing a request on behalf of another student' do + post :create_for_student, params: { + course_id: course.id, + request: { user_id: user.id, assignment_id: assignment.id, reason: 'x', requested_due_date: Date.tomorrow.to_s, due_time: '10:00' } + } + + expect(response).to redirect_to(courses_path) + expect(flash[:alert]).to include('do not have access') + end + end + + describe 'access control' do + it 'redirects to the courses list when the course does not exist' do + get :index, params: { course_id: 0 } + + expect(response).to redirect_to(courses_path) + expect(flash[:alert]).to eq('Course not found.') + end + + it 'blocks students when extensions are disabled for the course' do + course.course_settings.update!(enable_extensions: false) + + get :index, params: { course_id: course.id } + + expect(response).to redirect_to(courses_path) + expect(flash[:alert]).to eq('Extensions are not enabled for this course.') + end + + it 'still allows staff when extensions are disabled' do + course.course_settings.update!(enable_extensions: false) + session[:user_id] = instructor.canvas_uid + Enrollment.create!(user: instructor, course: course, role: 'teacher') + + get :index, params: { course_id: course.id } + + expect(response).to have_http_status(:ok) + end + end end diff --git a/spec/controllers/session_controller_spec.rb b/spec/controllers/session_controller_spec.rb index 476dff37..ca65dc4f 100644 --- a/spec/controllers/session_controller_spec.rb +++ b/spec/controllers/session_controller_spec.rb @@ -227,7 +227,7 @@ get :omniauth_callback, params: { provider: 'developer' } user = User.find_by(canvas_uid: 'test@example.com') - enrollment = UserToCourse.find_by(user_id: user.id, course_id: test_course.id) + enrollment = Enrollment.find_by(user_id: user.id, course_id: test_course.id) expect(enrollment).to be_present expect(enrollment.role).to eq('student') diff --git a/spec/factories/course_settings.rb b/spec/factories/course_settings.rb deleted file mode 100644 index 8a28112c..00000000 --- a/spec/factories/course_settings.rb +++ /dev/null @@ -1,52 +0,0 @@ -# rubocop:disable Layout/LineLength -# == Schema Information -# -# Table name: course_settings -# -# id :bigint not null, primary key -# auto_approve_days :integer default(0) -# auto_approve_extended_request_days :integer default(0) -# email_subject :string default("Extension Request Status: {{status}} - {{course_code}}") -# email_template :text default("Dear {{student_name}},\n\nYour 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") -# enable_emails :boolean default(FALSE) -# enable_extensions :boolean default(FALSE) -# enable_gradescope :boolean default(FALSE) -# enable_min_hours_before_deadline :boolean default(TRUE), not null -# enable_slack_webhook_url :boolean -# extend_late_due_date :boolean default(TRUE), not null -# gradescope_course_url :string -# max_auto_approve :integer default(0) -# min_hours_before_deadline :integer default(0), not null -# reply_email :string -# slack_webhook_url :string -# created_at :datetime not null -# updated_at :datetime not null -# course_id :bigint not null -# -# Indexes -# -# index_course_settings_on_course_id (course_id) -# -# Foreign Keys -# -# fk_rails_... (course_id => courses.id) -# -# rubocop:enable Layout/LineLength - -FactoryBot.define do - factory :course_settings do - 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 diff --git a/spec/factories/courses.rb b/spec/factories/courses.rb index cde8c2ec..aa4824ff 100644 --- a/spec/factories/courses.rb +++ b/spec/factories/courses.rb @@ -26,7 +26,9 @@ after(:create) do |course| lms = Lms.find_by(id: 1) || create(:lms, id: 1, lms_name: 'Canvas') - create(:course_settings, course: course) + # Course settings are created automatically with the course; factory + # courses default to extensions enabled since most specs need them. + course.course_settings.update!(enable_extensions: true) create(:form_setting, course: course) course_to_lms = create( :course_to_lms, diff --git a/spec/factories/user_to_course.rb b/spec/factories/enrollment.rb similarity index 88% rename from spec/factories/user_to_course.rb rename to spec/factories/enrollment.rb index 36293555..c7c392a1 100644 --- a/spec/factories/user_to_course.rb +++ b/spec/factories/enrollment.rb @@ -1,5 +1,5 @@ FactoryBot.define do - factory :user_to_course do + factory :enrollment do association :user association :course role { 'student' } diff --git a/spec/factories/users.rb b/spec/factories/users.rb index b73d2d66..6df7bb49 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -36,7 +36,7 @@ after(:create) do |user, evaluator| evaluator.courses.each do |course| - create(:user_to_course, user: user, course: course, role: evaluator.role) + create(:enrollment, user: user, course: course, role: evaluator.role) end end @@ -48,15 +48,15 @@ end factory :teacher do - after(:create) { |user| create(:user_to_course, user: user, role: 'teacher') } + after(:create) { |user| create(:enrollment, user: user, role: 'teacher') } end factory :ta do - after(:create) { |user| create(:user_to_course, user: user, role: 'ta') } + after(:create) { |user| create(:enrollment, user: user, role: 'ta') } end factory :student do - after(:create) { |user| create(:user_to_course, user: user, role: 'student') } + after(:create) { |user| create(:enrollment, user: user, role: 'student') } end end end diff --git a/spec/features/accessibility_spec.rb b/spec/features/accessibility_spec.rb index 3f614ea2..3fd56575 100644 --- a/spec/features/accessibility_spec.rb +++ b/spec/features/accessibility_spec.rb @@ -41,18 +41,18 @@ let!(:c3_assignment4) { create(:assignment, name: 'CS Project 2', course_to_lms: course_to_lms3, external_assignment_id: 'cs_p2', due_date: 30.days.from_now, late_due_date: 32.days.from_now) } let!(:c3_assignment5) { create(:assignment, name: 'CS Final', course_to_lms: course_to_lms3, external_assignment_id: 'cs_final', due_date: 37.days.from_now, late_due_date: 37.days.from_now) } - let!(:teacher1_course1) { create(:user_to_course, user: teacher1, course: course1, role: 'teacher') } - let!(:teacher1_course2) { create(:user_to_course, user: teacher1, course: course2, role: 'teacher') } - let!(:teacher2_course3) { create(:user_to_course, user: teacher2, course: course3, role: 'teacher') } - - let!(:student1_course1) { create(:user_to_course, user: student1, course: course1, role: 'student') } - let!(:student1_course2) { create(:user_to_course, user: student1, course: course2, role: 'student') } - let!(:student2_course1) { create(:user_to_course, user: student2, course: course1, role: 'student') } - let!(:student2_course3) { create(:user_to_course, user: student2, course: course3, role: 'student') } - let!(:student3_course2) { create(:user_to_course, user: student3, course: course2, role: 'student') } - let!(:student3_course3) { create(:user_to_course, user: student3, course: course3, role: 'student') } - let!(:student4_course1) { create(:user_to_course, user: student4, course: course1, role: 'student') } - let!(:student4_course3) { create(:user_to_course, user: student4, course: course3, role: 'student') } + let!(:teacher1_course1) { create(:enrollment, user: teacher1, course: course1, role: 'teacher') } + let!(:teacher1_course2) { create(:enrollment, user: teacher1, course: course2, role: 'teacher') } + let!(:teacher2_course3) { create(:enrollment, user: teacher2, course: course3, role: 'teacher') } + + let!(:student1_course1) { create(:enrollment, user: student1, course: course1, role: 'student') } + let!(:student1_course2) { create(:enrollment, user: student1, course: course2, role: 'student') } + let!(:student2_course1) { create(:enrollment, user: student2, course: course1, role: 'student') } + let!(:student2_course3) { create(:enrollment, user: student2, course: course3, role: 'student') } + let!(:student3_course2) { create(:enrollment, user: student3, course: course2, role: 'student') } + let!(:student3_course3) { create(:enrollment, user: student3, course: course3, role: 'student') } + let!(:student4_course1) { create(:enrollment, user: student4, course: course1, role: 'student') } + let!(:student4_course3) { create(:enrollment, user: student4, course: course3, role: 'student') } let!(:c1_request1) { create(:request, :approved, course: course1, user: student1, assignment: c1_assignment1, reason: 'Medical emergency', requested_due_date: 12.days.from_now) } let!(:c1_request2) { create(:request, course: course1, user: student2, assignment: c1_assignment2, reason: 'Family emergency', requested_due_date: 18.days.from_now) } @@ -102,9 +102,9 @@ custom_q2: 'Will this delay affect your other coursework?') end - let!(:course_settings1) { create(:course_settings, course: course1, auto_approve_days: 2) } - let!(:course_settings2) { create(:course_settings, course: course2, auto_approve_days: 1) } - let!(:course_settings3) { create(:course_settings, course: course3, auto_approve_days: 3) } + let!(:course_settings1) { course1.course_settings.tap { |cs| cs.update!(auto_approve_days: 2) } } + let!(:course_settings2) { course2.course_settings.tap { |cs| cs.update!(auto_approve_days: 1) } } + let!(:course_settings3) { course3.course_settings.tap { |cs| cs.update!(auto_approve_days: 3) } } let!(:extension1) { create(:extension, assignment: c1_assignment1, student_email: student1.email, initial_due_date: c1_assignment1.due_date, new_due_date: 12.days.from_now) } let!(:extension2) { create(:extension, assignment: c2_assignment1, student_email: student1.email, initial_due_date: c2_assignment1.due_date, new_due_date: 13.days.from_now) } diff --git a/spec/models/assignment_spec.rb b/spec/models/assignment_spec.rb index 0e7ba64d..7965a767 100644 --- a/spec/models/assignment_spec.rb +++ b/spec/models/assignment_spec.rb @@ -9,11 +9,17 @@ # name :string # created_at :datetime not null # updated_at :datetime not null +# course_id :bigint not null # course_to_lms_id :bigint not null # external_assignment_id :string # +# Indexes +# +# index_assignments_on_course_id (course_id) +# # Foreign Keys # +# fk_rails_... (course_id => courses.id) # fk_rails_... (course_to_lms_id => course_to_lmss.id) # require 'rails_helper' @@ -23,6 +29,20 @@ expect(build(:assignment)).to be_valid end + describe 'course denormalization' do + it 'defaults course to the course_to_lms course on save' do + course = create(:course) + assignment = create(:assignment, course_to_lms: course.course_to_lms(1)) + expect(assignment.course).to eq(course) + end + + it 'is invalid without a course or course_to_lms' do + assignment = build(:assignment, course_to_lms: nil) + expect(assignment).not_to be_valid + expect(assignment.errors[:course]).to include('must exist') + end + end + describe 'custom validations' do context 'enabled_requires_date_present' do it 'is valid when enabled is false and due_date is blank' do diff --git a/spec/models/course_settings_spec.rb b/spec/models/course_settings_spec.rb index e1ea084c..fe9f216e 100644 --- a/spec/models/course_settings_spec.rb +++ b/spec/models/course_settings_spec.rb @@ -16,6 +16,8 @@ # gradescope_course_url :string # max_auto_approve :integer default(0) # min_hours_before_deadline :integer default(0), not null +# pending_notification_email :string +# pending_notification_frequency :string # reply_email :string # slack_webhook_url :string # created_at :datetime not null @@ -24,7 +26,7 @@ # # Indexes # -# index_course_settings_on_course_id (course_id) +# index_course_settings_on_course_id (course_id) UNIQUE # # Foreign Keys # @@ -40,6 +42,12 @@ it 'belongs to course' do expect(course_settings.course).to eq(course) end + + it 'does not allow a second settings record for the same course' do + duplicate = described_class.new(course: course) + expect(duplicate).not_to be_valid + expect(duplicate.errors[:course_id]).to include('has already been taken') + end end describe 'minimum hours before deadline defaults' do @@ -308,8 +316,7 @@ describe 'extend_late_due_date setting' do it 'defaults to true for new course settings' do new_course = create(:course, canvas_id: 'canvas_new', course_name: 'New Course', course_code: 'NEW101') - new_settings = described_class.create!(course: new_course) - expect(new_settings.extend_late_due_date).to be true + expect(new_course.course_settings.extend_late_due_date).to be true end it 'can be set to false' do diff --git a/spec/models/course_spec.rb b/spec/models/course_spec.rb index 58cf48f9..dab49dad 100644 --- a/spec/models/course_spec.rb +++ b/spec/models/course_spec.rb @@ -37,21 +37,144 @@ { "id": 240, "name": "Sherri Johnson", "created_at": "2025-05-05T11:57:36-07:00", "sortable_name": "Johnson, Sherri", "short_name": "Sherri Johnson", "sis_user_id": "216573718", "integration_id": "sherri.johnson53", "sis_import_id": 5, "login_id": "sherri.johnson53@example.com", "email": "sherri.johnson53@example.com", "has_non_collaborative_groups": false } ] + describe 'course settings creation' do + it 'automatically creates course settings with defaults when a course is created' do + course = described_class.create!(course_name: 'Settings Test', canvas_id: 'canvas_settings', course_code: 'SET101') + + expect(course.course_settings).to be_persisted + expect(course.course_settings.enable_extensions).to be false + expect(course.course_settings.extend_late_due_date).to be true + end + + it 'keeps settings built before the course is saved' do + course = described_class.new(course_name: 'Prebuilt Settings', canvas_id: 'canvas_prebuilt', course_code: 'PRE101') + course.build_course_settings(enable_extensions: true) + course.save! + + expect(course.course_settings.reload.enable_extensions).to be true + expect(CourseSettings.where(course_id: course.id).count).to eq(1) + end + end + + describe '#requests_enabled?' do + it 'is true when extensions are enabled in the course settings' do + course = create(:course) + course.course_settings.update!(enable_extensions: true) + + expect(course.requests_enabled?).to be true + end + + it 'is false when extensions are disabled in the course settings' do + course = create(:course) + course.course_settings.update!(enable_extensions: false) + + expect(course.requests_enabled?).to be false + end + end + + describe '#canvas_id' do + let(:course) { described_class.create!(canvas_id: 'canvas_cid', course_name: 'Test', course_code: 'TEST101') } + + it 'returns the external course id of the Canvas link' do + CourseToLms.create!(course: course, lms_id: CANVAS_LMS_ID, external_course_id: '456') + + expect(course.canvas_id).to eq('456') + end + + it 'prefers a populated link even when a blank duplicate exists' do + # Order of creation is intentionally "blank first" to guard against the + # non-deterministic find_by that previously returned an arbitrary row. + CourseToLms.create!(course: course, lms_id: CANVAS_LMS_ID, external_course_id: nil) + CourseToLms.create!(course: course, lms_id: CANVAS_LMS_ID, external_course_id: '456') + + expect(course.canvas_id).to eq('456') + end + end + + describe '#enabled_assignments' do + it 'returns only enabled assignments belonging to the course' do + course = create(:course) + other_course = create(:course) + enabled = create(:assignment, course_to_lms: course.course_to_lms(1), enabled: true) + create(:assignment, course_to_lms: course.course_to_lms(1), enabled: false) + create(:assignment, course_to_lms: other_course.course_to_lms(1), enabled: true) + + expect(course.enabled_assignments).to contain_exactly(enabled) + end + end + describe '#staff_user_for_auto_approval' do - it 'returns the correct user for auto approval' do - course = described_class.create!(canvas_id: 'canvas_123', course_name: 'Test', course_code: 'TEST101') - user = User.create!(email: 'test@example.com', canvas_uid: '123') + let(:course) { described_class.create!(canvas_id: 'canvas_123', course_name: 'Test', course_code: 'TEST101') } + + before do Lms.find_or_create_by(id: 1) { |l| l.lms_name = 'Canvas'; l.use_auth_token = true } - user.lms_credentials.create!( - lms_id: 1, - token: 'valid_token', - refresh_token: 'refresh_token', + end + + def create_staff(email, canvas_uid, role, with_credentials: true) + user = User.create!(email: email, canvas_uid: canvas_uid) + if with_credentials + user.lms_credentials.create!( + lms_id: 1, + token: 'valid_token', + refresh_token: 'refresh_token', + expire_time: 1.hour.from_now + ) + end + Enrollment.create!(user: user, course: course, role: role) + user + end + + it 'returns a staff user who has Canvas credentials' do + user = create_staff('test@example.com', '123', 'ta') + + expect(course.staff_user_for_auto_approval).to eq(user) + end + + it 'skips staff without Canvas credentials, such as users synced from the Canvas roster' do + create_staff('synced-ta@example.com', '124', 'ta', with_credentials: false) + credentialed = create_staff('logged-in-teacher@example.com', '125', 'teacher') + + expect(course.staff_user_for_auto_approval).to eq(credentialed) + end + + it 'ignores non-Canvas credentials' do + other_lms = Lms.find_or_create_by(id: 3) { |l| l.lms_name = 'other_lms'; l.use_auth_token = true } + user = create_staff('other-lms@example.com', '126', 'ta', with_credentials: false) + user.lms_credentials.create!(lms: other_lms, token: 't', refresh_token: 'r', expire_time: 1.hour.from_now) + + expect(course.staff_user_for_auto_approval).to be_nil + end + + it 'returns nil when no staff user has Canvas credentials' do + create_staff('synced-ta@example.com', '127', 'ta', with_credentials: false) + + expect(course.staff_user_for_auto_approval).to be_nil + end + + it 'ignores students with Canvas credentials' do + create_staff('student@example.com', '128', 'student') + + expect(course.staff_user_for_auto_approval).to be_nil + end + + it 'prefers the staff user whose credentials were refreshed most recently' do + course = described_class.create!(canvas_id: 'canvas_126', course_name: 'Test', course_code: 'TEST101') + + idle_ta = User.create!(email: 'idle_ta@example.com', canvas_uid: '127') + idle_ta.lms_credentials.create!( + lms_id: 1, token: 'stale', refresh_token: 'stale', + expire_time: 6.months.ago, updated_at: 6.months.ago + ) + Enrollment.create!(user: idle_ta, course: course, role: 'ta') + + active_teacher = User.create!(email: 'active_teacher@example.com', canvas_uid: '128') + active_teacher.lms_credentials.create!( + lms_id: 1, token: 'fresh', refresh_token: 'fresh', expire_time: 1.hour.from_now ) - UserToCourse.create!(user: user, course: course, role: 'ta') + Enrollment.create!(user: active_teacher, course: course, role: 'teacher') - staff_user = course.staff_user_for_auto_approval - expect(staff_user).to eq(user) + expect(course.staff_users_for_auto_approval).to eq([ active_teacher, idle_ta ]) end end @@ -59,7 +182,7 @@ it 'treats leadta enrollments as instructors' do course = described_class.create!(canvas_id: 'canvas_leadta', course_name: 'Test', course_code: 'TEST101') user = User.create!(email: 'leadta@example.com', canvas_uid: 'leadta_123') - UserToCourse.create!(user: user, course: course, role: 'leadta') + Enrollment.create!(user: user, course: course, role: 'leadta') expect(course.user_role(user)).to eq('instructor') end @@ -209,11 +332,11 @@ allow(user).to receive(:ensure_fresh_canvas_token!).and_return('fake_token') end - it 'creates user and user_to_course record' do + it 'creates user and enrollment record' do expect do course.sync_users_from_canvas(user.id, 'student') end.to change(User, :count).by(CANVAS_USERS.size).and( - change(UserToCourse, :count).by(CANVAS_USERS.size) + change(Enrollment, :count).by(CANVAS_USERS.size) ) end end diff --git a/spec/models/user_to_course_spec.rb b/spec/models/enrollment_spec.rb similarity index 73% rename from spec/models/user_to_course_spec.rb rename to spec/models/enrollment_spec.rb index 5f82f126..c444c8ae 100644 --- a/spec/models/user_to_course_spec.rb +++ b/spec/models/enrollment_spec.rb @@ -1,6 +1,6 @@ # == Schema Information # -# Table name: user_to_courses +# Table name: enrollments # # id :bigint not null, primary key # allow_extended_requests :boolean default(FALSE), not null @@ -13,8 +13,8 @@ # # Indexes # -# index_user_to_courses_on_course_id (course_id) -# index_user_to_courses_on_user_id (user_id) +# index_enrollments_on_course_id (course_id) +# index_enrollments_on_user_id (user_id) # # Foreign Keys # @@ -23,20 +23,20 @@ # require 'rails_helper' -RSpec.describe UserToCourse, type: :model do +RSpec.describe Enrollment, type: :model do describe 'Lead TA role support' do it 'treats leadta as a supported staff role' do - user_to_course = build(:user_to_course, role: 'leadta') + enrollment = build(:enrollment, role: 'leadta') expect(described_class.staff_roles).to include('leadta') expect(described_class.roles).to include('leadta') - expect(user_to_course).to be_staff + expect(enrollment).to be_staff end it 'treats leadta as a course admin role' do - user_to_course = build(:user_to_course, role: 'leadta') + enrollment = build(:enrollment, role: 'leadta') - expect(user_to_course).to be_course_admin + expect(enrollment).to be_course_admin end end @@ -56,9 +56,9 @@ describe '#display_role' do it 'formats leadta as Lead TA' do - user_to_course = build(:user_to_course, role: 'leadta') + enrollment = build(:enrollment, role: 'leadta') - expect(user_to_course.display_role).to eq('Lead TA') + expect(enrollment.display_role).to eq('Lead TA') end end end diff --git a/spec/models/lms_credential_spec.rb b/spec/models/lms_credential_spec.rb index 121115dc..2ea579f2 100644 --- a/spec/models/lms_credential_spec.rb +++ b/spec/models/lms_credential_spec.rb @@ -75,4 +75,108 @@ def self.mock_get_service(token, refresh_token) expect(MockCanvas.mock_get_service(credential.token, credential.refresh_token)).to eq('service_object') end end + + describe '#expires_soon?' do + let(:user) { User.create!(email: 'expiry@example.com') } + let!(:lms) { Lms.find_or_create_by(id: 1) { |lms| lms.lms_name = 'Canvas'; lms.use_auth_token = true } } + let(:credential) do + described_class.create!(user: user, lms: lms, token: 't', refresh_token: 'r', expire_time: expire_time) + end + + context 'when the token expires within the buffer' do + let(:expire_time) { 5.minutes.from_now } + + it 'returns true' do + expect(credential.expires_soon?).to be true + end + end + + context 'when the token expires well in the future' do + let(:expire_time) { 1.hour.from_now } + + it 'returns false' do + expect(credential.expires_soon?).to be false + end + end + + context 'when there is no expiry time' do + let(:expire_time) { nil } + + it 'returns false' do + expect(credential.expires_soon?).to be false + end + end + end + + describe '#refresh!' do + let(:user) { User.create!(email: 'refresh@example.com') } + let!(:lms) { Lms.find_or_create_by(id: 1) { |lms| lms.lms_name = 'Canvas'; lms.use_auth_token = true } } + let(:credential) do + described_class.create!( + user: user, + lms: lms, + token: 'old_token', + refresh_token: 'old_refresh', + expire_time: 5.minutes.from_now + ) + end + + it 'persists and returns the new token on success' do + new_token = instance_double(OAuth2::AccessToken, + token: 'new_token', + refresh_token: 'new_refresh', + expires_at: 1.hour.from_now.to_i) + allow(OAuth2::AccessToken).to receive(:from_hash).and_return(new_token) + allow(new_token).to receive(:refresh!).and_return(new_token) + + expect(credential.refresh!).to eq('new_token') + expect(credential.reload.token).to eq('new_token') + expect(credential.refresh_token).to eq('new_refresh') + end + + it 'keeps the old refresh token when Canvas does not rotate it' do + new_token = instance_double(OAuth2::AccessToken, + token: 'new_token', + refresh_token: nil, + expires_at: 1.hour.from_now.to_i) + allow(OAuth2::AccessToken).to receive(:from_hash).and_return(new_token) + allow(new_token).to receive(:refresh!).and_return(new_token) + + credential.refresh! + expect(credential.reload.refresh_token).to eq('old_refresh') + end + + it 'returns nil when there is no refresh token' do + credential.update!(refresh_token: nil) + + expect(credential.refresh!).to be_nil + end + + it 'returns nil and keeps the credential on a transient failure (no OAuth error code)' do + fake_response = instance_double(OAuth2::Response, parsed: {}, status: 500, body: 'Internal Server Error') + allow(OAuth2::AccessToken).to receive(:from_hash).and_raise(OAuth2::Error.new(fake_response)) + + expect(credential.refresh!).to be_nil + expect(credential.reload.token).to eq('old_token') + end + + it 'returns nil and keeps the credential on a non-grant OAuth error (e.g. misconfigured client)' do + fake_response = instance_double(OAuth2::Response, status: 401, body: '{"error":"invalid_client"}', + parsed: { 'error' => 'invalid_client' }) + allow(OAuth2::AccessToken).to receive(:from_hash).and_raise(OAuth2::Error.new(fake_response)) + + expect(credential.refresh!).to be_nil + expect(credential.reload.token).to eq('old_token') + end + + it 'deletes the credential when Canvas reports the refresh token is dead (invalid_grant)' do + fake_response = instance_double(OAuth2::Response, status: 400, body: '{"error":"invalid_grant"}', + parsed: { 'error' => 'invalid_grant', + 'error_description' => 'refresh_token not found' }) + allow(OAuth2::AccessToken).to receive(:from_hash).and_raise(OAuth2::Error.new(fake_response)) + + expect(credential.refresh!).to be_nil + expect(described_class.exists?(credential.id)).to be false + end + end end diff --git a/spec/models/request_spec.rb b/spec/models/request_spec.rb index b6d406fd..52404112 100644 --- a/spec/models/request_spec.rb +++ b/spec/models/request_spec.rb @@ -49,14 +49,14 @@ due_date: 2.days.from_now ) end - # TODO: Move this to course model initialization let(:course_settings) do - CourseSettings.create!( - course: course, - enable_extensions: true, - auto_approve_days: 3, - max_auto_approve: 2 - ) + course.course_settings.tap do |cs| + cs.update!( + enable_extensions: true, + auto_approve_days: 3, + max_auto_approve: 2 + ) + end end let(:request) do described_class.create!( @@ -69,8 +69,8 @@ end before do - UserToCourse.create!(user: user, course: course, role: 'student') - create(:lms) + Enrollment.create!(user: user, course: course, role: 'student') + Lms.find_or_create_by(id: 1) { |l| l.lms_name = 'Canvas'; l.use_auth_token = true } user.lms_credentials.create!( lms_id: 1, token: 'fake_token', @@ -164,15 +164,6 @@ end end - context 'when course settings do not exist' do - it 'returns false' do - course.course_settings&.destroy - course.reload - expect(request.course.course_settings).to be_nil - expect(request.auto_approval_eligible_for_course?).to be false - end - end - context 'when extensions are disabled' do before do course_settings.update(enable_extensions: false) @@ -214,25 +205,14 @@ end end - # TODO: Investigate the odd relationship with `course_settings` - # Consider dropping the don't exist spec in favor a validation on `Course`. describe '#eligible_for_auto_approval?' do context 'when all conditions are met' do it 'returns true' do - course_settings # ensure this exists/is created. + course_settings # ensure auto-approval settings are configured expect(request.eligible_for_auto_approval?).to be true end end - context 'when course settings do not exist' do - it 'returns false' do - course.course_settings&.destroy - course.reload - expect(course.course_settings).to be_nil - expect(request.eligible_for_auto_approval?).to be false - end - end - context 'when auto_approve_days is zero' do before do course_settings.update(auto_approve_days: 0) @@ -344,7 +324,7 @@ context 'when student has allow_extended_requests and requests more than auto_approve_days' do before do course_settings.update(auto_approve_days: 3, auto_approve_extended_request_days: 7) - UserToCourse.find_by(user: user, course: course).update!(allow_extended_requests: true) + Enrollment.find_by(user: user, course: course).update!(allow_extended_requests: true) end it 'returns true when within extended request days' do @@ -358,10 +338,27 @@ end end + context 'when student has allow_extended_requests but extended days are disabled (0)' do + before do + course_settings.update(auto_approve_days: 2, auto_approve_extended_request_days: 0) + Enrollment.find_by(user: user, course: course).update!(allow_extended_requests: true) + end + + it 'falls back to the standard auto_approve_days window' do + request.update(requested_due_date: assignment.due_date + 2.days) + expect(request.eligible_for_auto_approval?).to be true + end + + it 'still rejects requests beyond the standard window' do + request.update(requested_due_date: assignment.due_date + 3.days) + expect(request.eligible_for_auto_approval?).to be false + end + end + context 'when student does not have allow_extended_requests' do before do course_settings.update(auto_approve_days: 3, auto_approve_extended_request_days: 7) - UserToCourse.find_by(user: user, course: course).update!(allow_extended_requests: false) + Enrollment.find_by(user: user, course: course).update!(allow_extended_requests: false) end it 'returns false when exceeding standard auto_approve_days' do @@ -378,7 +375,7 @@ context 'when student requests exactly the max extended days' do before do course_settings.update(auto_approve_days: 3, auto_approve_extended_request_days: 7) - UserToCourse.find_by(user: user, course: course).update!(allow_extended_requests: true) + Enrollment.find_by(user: user, course: course).update!(allow_extended_requests: true) end it 'returns true at the boundary' do @@ -400,7 +397,7 @@ context 'when user has no enrollment record' do before do course_settings.update(auto_approve_days: 3, auto_approve_extended_request_days: 7) - UserToCourse.find_by(user: user, course: course).destroy + Enrollment.find_by(user: user, course: course).destroy end it 'returns false for all requests' do @@ -412,7 +409,7 @@ context 'when extended student hits max_auto_approve limit' do before do course_settings.update(auto_approve_days: 3, auto_approve_extended_request_days: 7, max_auto_approve: 1) - UserToCourse.find_by(user: user, course: course).update!(allow_extended_requests: true) + Enrollment.find_by(user: user, course: course).update!(allow_extended_requests: true) described_class.create!( user: user, course: course, @@ -461,7 +458,7 @@ context 'when auto approval is eligible and successful' do before do - allow(request).to receive_messages(auto_approval_eligible_for_course?: true, eligible_for_auto_approval?: true, auto_approve: true) + allow(request).to receive_messages(eligible_for_auto_approval?: true, auto_approve: true) end it 'returns true' do @@ -474,9 +471,9 @@ end end - context 'when course is not eligible for auto approval' do + context 'when request is not eligible for auto approval' do before do - allow(request).to receive(:auto_approval_eligible_for_course?).and_return(false) + allow(request).to receive(:eligible_for_auto_approval?).and_return(false) end it 'returns false' do @@ -489,24 +486,145 @@ end end - context 'when user has no LMS credentials' do + # The production failure mode: staff synced from the Canvas roster have no + # stored credentials, so no staff token is available to post the approval. + context 'when no staff user has Canvas credentials' do before do - allow(request).to receive(:auto_approval_eligible_for_course?).and_return(true) - user.lms_credentials.destroy_all + allow(request).to receive(:eligible_for_auto_approval?).and_return(true) + course.staff_users.each { |staff| staff.lms_credentials.destroy_all } end - it 'returns false' do + it 'returns false and leaves the request pending' do expect(request.try_auto_approval(nil)).to be false + expect(request.reload.status).to eq('pending') + end + + it 'logs the reason the auto-approval was skipped' do + allow(Rails.logger).to receive(:warn) + request.try_auto_approval(nil) + expect(Rails.logger).to have_received(:warn).with(/no staff member has connected a Canvas account/) + end + + it 'records why auto-approval broke down' do + request.try_auto_approval(nil) + expect(request.auto_approval_breakdown).to include('no staff member has connected') + end + + it 'does not call auto_approve' do + expect(request).not_to receive(:auto_approve) + request.try_auto_approval(nil) end end - context 'when request is not eligible for auto approval' do + context "when the first staff user's token cannot be refreshed" do + let(:stale_staff) { course.staff_users.first } + let(:working_staff) { course.staff_users.second } + before do - allow(request).to receive_messages(auto_approval_eligible_for_course?: true, eligible_for_auto_approval?: false) + allow(request).to receive_messages(auto_approval_eligible_for_course?: true, eligible_for_auto_approval?: true, auto_approve: true) + allow(course).to receive(:staff_users_for_auto_approval).and_return([ stale_staff, working_staff ]) + allow(stale_staff).to receive(:ensure_fresh_canvas_token!).and_return(nil) + allow(working_staff).to receive(:ensure_fresh_canvas_token!).and_return('fresh_token') end - it 'returns false' do + it 'falls back to the next staff user and approves' do + expect(request.try_auto_approval(user)).to be true + expect(request.auto_approval_breakdown).to be_nil + end + + it 'only calls auto_approve once' do + expect(request).to receive(:auto_approve).once.and_return(true) + request.try_auto_approval(user) + end + end + + context 'when the LMS rejects the approval for the first staff user' do + let(:removed_staff) { course.staff_users.first } + let(:working_staff) { course.staff_users.second } + + before do + allow(request).to receive_messages(auto_approval_eligible_for_course?: true, eligible_for_auto_approval?: true) + allow(course).to receive(:staff_users_for_auto_approval).and_return([ removed_staff, working_staff ]) + allow(removed_staff).to receive(:ensure_fresh_canvas_token!).and_return('token_without_course_access') + allow(working_staff).to receive(:ensure_fresh_canvas_token!).and_return('fresh_token') + end + + it 'falls back to the next staff user and approves' do + allow(request).to receive(:auto_approve).and_return(false, true) + expect(request.try_auto_approval(user)).to be true + expect(request.auto_approval_breakdown).to be_nil + end + end + + context 'when every staff Canvas token is unusable' do + before do + allow(request).to receive_messages(auto_approval_eligible_for_course?: true, eligible_for_auto_approval?: true) + staff = course.staff_users + staff.each do |staff_user| + allow(staff_user).to receive(:ensure_fresh_canvas_token!).and_return(nil) + end + allow(course).to receive(:staff_users_for_auto_approval).and_return(staff) + end + + it 'returns false and records why auto-approval broke down' do expect(request.try_auto_approval(user)).to be false + expect(request.auto_approval_breakdown).to include('Canvas access is currently working') + end + + it 'does not call auto_approve' do + expect(request).not_to receive(:auto_approve) + request.try_auto_approval(user) + end + end + + context 'when the first staff enrollment has no credentials but another staff user does' do + before do + allow(request).to receive_messages(eligible_for_auto_approval?: true, auto_approve: true) + course.enrollments.where(role: Enrollment.staff_roles).order(:id).first.user.lms_credentials.destroy_all + end + + it 'still auto-approves using the credentialed staff user' do + expect(request).to receive(:auto_approve).with(canvas_facade) + expect(request.try_auto_approval(nil)).to be true + end + end + + context 'when the staff token is expiring and cannot be refreshed' do + before do + allow(request).to receive(:eligible_for_auto_approval?).and_return(true) + course.staff_users.each { |staff| staff.lms_credentials.each { |cred| cred.update!(expire_time: 1.minute.from_now) } } + allow_any_instance_of(LmsCredential).to receive(:refresh!).and_return(nil) + end + + it 'returns false rather than posting to Canvas with a stale token' do + expect(request).not_to receive(:auto_approve) + expect(request.try_auto_approval(nil)).to be false + end + end + end + + describe '#process_created_request' do + before do + course.course_settings.update!(slack_webhook_url: 'https://hooks.slack.com/services/TEST') + allow(SlackNotifier).to receive(:notify).and_return(true) + end + + it 'warns course staff in Slack when auto-approval broke down' do + allow(request).to receive_messages(auto_approval_eligible_for_course?: true, eligible_for_auto_approval?: true) + allow(course).to receive(:staff_users_for_auto_approval).and_return([]) + + request.process_created_request(user) + + expect(SlackNotifier).to have_received(:notify).with(a_string_including(':warning:'), anything) + end + + it 'does not warn when the request is pending for ordinary reasons' do + allow(request).to receive(:try_auto_approval).and_return(false) + + request.process_created_request(user) + + expect(SlackNotifier).to have_received(:notify) do |message, _url| + expect(message).not_to include(':warning:') end end end @@ -515,7 +633,7 @@ let(:canvas_facade) { instance_double(CanvasFacade) } before do - allow(request).to receive_messages(eligible_for_auto_approval?: true, approve: true) + allow(request).to receive(:approve).and_return(true) end it 'calls approve with the system user' do @@ -543,31 +661,6 @@ end end - context 'when the request is not eligible for auto approval' do - before do - allow(request).to receive(:eligible_for_auto_approval?).and_return(false) - end - - it 'returns false' do - expect(request.auto_approve(canvas_facade)).to be false - end - - it 'does not call approve' do - expect(request).not_to receive(:approve) - request.auto_approve(canvas_facade) - end - end - - context 'when no system user can be found or created' do - before do - allow(SystemUserService).to receive_messages(auto_approval_user: nil, ensure_auto_approval_user_exists: nil) - end - - it 'returns false' do - expect(request.auto_approve(canvas_facade)).to be false - end - end - context 'when approve returns false' do before do allow(request).to receive(:approve).and_return(false) @@ -752,6 +845,51 @@ end end + describe '#pending?' do + it 'is true for a pending request' do + expect(request.pending?).to be(true) + end + + it 'is false once the request is approved or denied' do + request.update!(status: 'denied') + expect(request.pending?).to be(false) + end + end + + describe '.pending' do + it 'returns only requests in the pending status' do + pending = request + denied = described_class.create!(user: user, course: course, assignment: assignment, + reason: 'x', requested_due_date: 4.days.from_now, status: 'denied') + + expect(described_class.pending).to include(pending) + expect(described_class.pending).not_to include(denied) + end + end + + describe '#approve_by' do + let(:lms_facade) { class_double(CanvasFacade) } + let(:user_facade) { instance_double(CanvasFacade) } + + it 'provisions through the assignment LMS facade on behalf of the acting user' do + allow(assignment).to receive(:lms_facade).and_return(lms_facade) + allow(request).to receive(:assignment).and_return(assignment) + allow(lms_facade).to receive(:from_user).with(instructor).and_return(user_facade) + allow(request).to receive(:approve).with(user_facade, instructor).and_return(true) + + expect(request.approve_by(instructor)).to be(true) + expect(lms_facade).to have_received(:from_user).with(instructor) + end + + it 'returns false without approving when the assignment has no LMS facade' do + allow(assignment).to receive(:lms_facade).and_return(nil) + allow(request).to receive(:assignment).and_return(assignment) + + expect(request).not_to receive(:approve) + expect(request.approve_by(instructor)).to be(false) + end + end + describe '#date_calculator' do it 'returns an AssignmentDateCalculator instance' do expect(request.date_calculator).to be_a(AssignmentDateCalculator) @@ -838,11 +976,7 @@ context 'when extend_late_due_date setting is true (default)' do before do - CourseSettings.create!( - course: course, - enable_extensions: true, - extend_late_due_date: true - ) + course.course_settings.update!(extend_late_due_date: true) end it 'shifts the late due date by the same delta as the extension' do @@ -857,11 +991,7 @@ context 'when extend_late_due_date setting is false' do before do - CourseSettings.create!( - course: course, - enable_extensions: true, - extend_late_due_date: false - ) + course.course_settings.update!(extend_late_due_date: false) end context 'when original late due date is later than extended due date' do @@ -887,29 +1017,8 @@ end end - context 'when extend_late_due_date setting is nil (defaults to true)' do - before do - # Create settings without explicitly setting extend_late_due_date - # This simulates existing courses before the migration - cs = CourseSettings.create!( - course: course, - enable_extensions: true - ) - # Manually set to nil to simulate pre-migration state - # cs.update_column(:extend_late_due_date, nil) - cs.extend_late_due_date = nil - end - - it 'defaults to shifting the late due date by the extension delta' do - result = request_with_late_due_date.calculate_new_late_due_date - expected = Time.zone.parse('2025-01-20 23:59:00') - expect(result).to be_within(1.second).of(expected) - end - end - - context 'when course has no course settings' do - it 'defaults to shifting the late due date (extend_late_due_date = true behavior)' do - # No course settings means nil, which defaults to true + context 'when course settings are untouched (extend_late_due_date defaults to true)' do + it 'shifts the late due date by the extension delta' do result = request_with_late_due_date.calculate_new_late_due_date expected = Time.zone.parse('2025-01-20 23:59:00') expect(result).to be_within(1.second).of(expected) @@ -926,16 +1035,17 @@ end let(:course_settings) do - CourseSettings.create!( - course: course, - enable_emails: true, - reply_email: 'instructor@example.com', - email_subject: 'Extension for {{student_name}}', - email_template: <<~TEMPLATE - Dear {{student_name}}, - Your extension request has been {{status}}. - TEMPLATE - ) + course.course_settings.tap do |cs| + cs.update!( + enable_emails: true, + reply_email: 'instructor@example.com', + email_subject: 'Extension for {{student_name}}', + email_template: <<~TEMPLATE + Dear {{student_name}}, + Your extension request has been {{status}}. + TEMPLATE + ) + end end it 'calls EmailService.send_email with correct parameters' do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index feabdbc3..148c7ac3 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -20,48 +20,6 @@ require 'rails_helper' RSpec.describe User, type: :model do - describe '#token_expired?' do - let(:user) { described_class.create!(email: 'test@example.com', canvas_uid: '123') } - - context 'when there are no credentials' do - it 'returns false' do - expect(user.token_expired?).to be false - end - end - - context 'when the token is still valid' do - before do - Lms.find_or_create_by(id: 1) { |lms| lms.lms_name = 'Canvas'; lms.use_auth_token = true } - user.lms_credentials.create!( - lms_id: 1, - token: 'valid_token', - refresh_token: 'refresh_token', - expire_time: 1.hour.from_now - ) - end - - it 'returns false' do - expect(user.token_expired?).to be false - end - end - - context 'when the token is expired' do - before do - Lms.find_or_create_by(id: 1) { |lms| lms.lms_name = 'Canvas'; lms.use_auth_token = true } - user.lms_credentials.create!( - lms_id: 1, - token: 'expired_token', - refresh_token: 'refresh_token', - expire_time: 1.hour.ago - ) - end - - it 'returns true' do - expect(user.token_expired?).to be true - end - end - end - describe '#canvas_credentials' do let(:user) { described_class.create!(email: 'test@example.com', canvas_uid: '123') } @@ -82,6 +40,28 @@ describe '#ensure_fresh_canvas_token!' do let(:user) { described_class.create!(email: 'test@example.com', canvas_uid: '123') } + context 'when the user has no credentials' do + it 'returns nil' do + expect(user.ensure_fresh_canvas_token!).to be_nil + end + end + + context 'when the user only has non-Canvas credentials' do + before do + other_lms = Lms.find_or_create_by(id: 3) { |lms| lms.lms_name = 'other_lms'; lms.use_auth_token = true } + user.lms_credentials.create!( + lms: other_lms, + token: 'other_token', + refresh_token: 'refresh_token', + expire_time: 1.hour.from_now + ) + end + + it 'returns nil' do + expect(user.ensure_fresh_canvas_token!).to be_nil + end + end + context 'when token does not expire soon' do before do Lms.find_or_create_by(id: 1) { |lms| lms.lms_name = 'Canvas'; lms.use_auth_token = true } @@ -93,13 +73,14 @@ ) end - it 'returns the current token' do + it 'returns the current token without refreshing' do + expect_any_instance_of(LmsCredential).not_to receive(:refresh!) expect(user.ensure_fresh_canvas_token!).to eq('valid_token') end end - context 'when token expires soon and is refreshed' do - let(:credential) do + context 'when token expires soon' do + before do Lms.find_or_create_by(id: 1) { |lms| lms.lms_name = 'Canvas'; lms.use_auth_token = true } user.lms_credentials.create!( lms_id: 1, @@ -109,16 +90,16 @@ ) end - before { credential } + it 'refreshes the token and returns the new one' do + allow_any_instance_of(LmsCredential).to receive(:refresh!).and_return('refreshed_token') - it 'refreshes token and returns it' do - allow(user).to receive(:token_expires_soon?).and_return(true) - - allow_any_instance_of(SessionController).to receive(:refresh_user_token).and_return('refreshed_token') + expect(user.ensure_fresh_canvas_token!).to eq('refreshed_token') + end - result = user.ensure_fresh_canvas_token! + it 'returns nil when the refresh fails, rather than a stale token' do + allow_any_instance_of(LmsCredential).to receive(:refresh!).and_return(nil) - expect(result).to eq('stale_token') # Still returns the credential.token + expect(user.ensure_fresh_canvas_token!).to be_nil end end end diff --git a/spec/requests/request_exports_spec.rb b/spec/requests/request_exports_spec.rb new file mode 100644 index 00000000..9293394a --- /dev/null +++ b/spec/requests/request_exports_spec.rb @@ -0,0 +1,63 @@ +require 'rails_helper' + +# The export endpoint is public and authenticated solely by the course's +# read-only API token (no user session), so it lives in its own controller and +# is exercised here as a request spec against the real route. +RSpec.describe 'Request exports', type: :request do + let(:course) { create(:course, course_name: 'Test Course', course_code: 'TST101') } + let(:student) { User.create!(email: 'student@example.com', canvas_uid: '123', name: 'Student') } + let(:instructor) { User.create!(email: 'instructor@example.com', canvas_uid: '566', name: 'Instructor') } + let(:assignment) do + Assignment.create!( + name: 'A1', + course_to_lms_id: course.course_to_lms(1).id, + external_assignment_id: 'x1', + due_date: 2.days.from_now, + enabled: true + ) + end + + it 'returns a CSV of requests when the token is valid' do + Request.create!(user: student, course: course, assignment: assignment, + reason: 'Need more time', requested_due_date: 4.days.from_now, status: 'pending') + + get export_course_requests_path(course), params: { readonly_api_token: course.readonly_api_token } + + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq('text/csv') + expect(response.body).to include('Student') + expect(response.body).to include('A1') + end + + it 'filters the export by status when requested' do + Request.create!(user: student, course: course, assignment: assignment, + reason: 'Pending one', requested_due_date: 4.days.from_now, status: 'pending') + Request.create!(user: instructor, course: course, assignment: assignment, + reason: 'Approved one', requested_due_date: 4.days.from_now, status: 'approved') + + get export_course_requests_path(course), params: { readonly_api_token: course.readonly_api_token, status: 'approved' } + + expect(response.body).to include('Instructor') + expect(response.body).to include('approved') + expect(response.body).not_to include('pending') + end + + it 'rejects an invalid token' do + get export_course_requests_path(course), params: { readonly_api_token: 'wrong-token' } + + expect(response).to have_http_status(:unauthorized) + expect(response.body).to include('Invalid or missing API token') + end + + it 'rejects a missing token' do + get export_course_requests_path(course) + + expect(response).to have_http_status(:unauthorized) + end + + it 'rejects a request for a non-existent course' do + get export_course_requests_path(course_id: 0), params: { readonly_api_token: 'anything' } + + expect(response).to have_http_status(:unauthorized) + end +end diff --git a/spec/services/assignment_date_calculator_spec.rb b/spec/services/assignment_date_calculator_spec.rb index 558f8796..61819a98 100644 --- a/spec/services/assignment_date_calculator_spec.rb +++ b/spec/services/assignment_date_calculator_spec.rb @@ -52,7 +52,7 @@ end before do - UserToCourse.create!(user: user, course: course, role: 'student') + Enrollment.create!(user: user, course: course, role: 'student') end describe '#calculate' do @@ -60,7 +60,7 @@ calculator = described_class.new( assignment: assignment_with_late_due_date, request: request_with_late_due_date, - course_settings: nil + course_settings: course.course_settings ) result = calculator.calculate @@ -77,7 +77,7 @@ calculator = described_class.new( assignment: assignment_with_late_due_date, request: request_with_late_due_date, - course_settings: nil + course_settings: course.course_settings ) expect(calculator.release_date).to be_nil @@ -89,7 +89,7 @@ calculator = described_class.new( assignment: assignment_with_late_due_date, request: request_with_late_due_date, - course_settings: nil + course_settings: course.course_settings ) expect(calculator.due_date).to eq(requested_due_date) @@ -99,11 +99,7 @@ describe '#late_due_date' do context 'when assignment has no late due date' do it 'returns nil regardless of course settings' do - course_settings = CourseSettings.create!( - course: course, - enable_extensions: true, - extend_late_due_date: true - ) + course_settings = course.course_settings.tap { |cs| cs.update!(extend_late_due_date: true) } calculator = described_class.new( assignment: assignment_without_late_due_date, @@ -115,11 +111,7 @@ end it 'returns nil when extend_late_due_date is false' do - course_settings = CourseSettings.create!( - course: course, - enable_extensions: true, - extend_late_due_date: false - ) + course_settings = course.course_settings.tap { |cs| cs.update!(extend_late_due_date: false) } calculator = described_class.new( assignment: assignment_without_late_due_date, @@ -134,11 +126,7 @@ context 'when assignment has a late due date' do context 'when extend_late_due_date setting is true (default)' do let(:course_settings) do - CourseSettings.create!( - course: course, - enable_extensions: true, - extend_late_due_date: true - ) + course.course_settings.tap { |cs| cs.update!(extend_late_due_date: true) } end it 'shifts the late due date by the same delta as the extension' do @@ -158,11 +146,7 @@ context 'when extend_late_due_date setting is false' do let(:course_settings) do - CourseSettings.create!( - course: course, - enable_extensions: true, - extend_late_due_date: false - ) + course.course_settings.tap { |cs| cs.update!(extend_late_due_date: false) } end context 'when original late due date is later than extended due date' do @@ -214,33 +198,12 @@ end end - context 'when extend_late_due_date setting is nil (defaults to true)' do - it 'defaults to shifting the late due date by the extension delta' do - # Create settings without explicitly setting extend_late_due_date - cs = CourseSettings.create!( - course: course, - enable_extensions: true - ) - # Manually set to nil to simulate pre-migration state - # Don't persist the nil value since the column doesn't allow it, but we want to test the behavior of the method when it encounters a nil value - cs.extend_late_due_date = nil - calculator = described_class.new( - assignment: assignment_with_late_due_date, - request: request_with_late_due_date, - course_settings: cs - ) - - expected = Time.zone.parse('2025-01-20 23:59:00') - expect(calculator.late_due_date).to be_within(1.second).of(expected) - end - end - - context 'when course_settings is nil' do - it 'defaults to shifting the late due date (extend_late_due_date = true behavior)' do + context 'when course settings are untouched (extend_late_due_date defaults to true)' do + it 'shifts the late due date by the extension delta' do calculator = described_class.new( assignment: assignment_with_late_due_date, request: request_with_late_due_date, - course_settings: nil + course_settings: course.course_settings ) expected = Time.zone.parse('2025-01-20 23:59:00') diff --git a/spec/tasks/lms_credentials_rake_spec.rb b/spec/tasks/lms_credentials_rake_spec.rb new file mode 100644 index 00000000..dcfcf7ce --- /dev/null +++ b/spec/tasks/lms_credentials_rake_spec.rb @@ -0,0 +1,44 @@ +require 'rails_helper' +require 'rake' + +RSpec.describe 'lms_credentials:purge', type: :task do + before(:all) do # rubocop:disable RSpec/BeforeAfterAll + Rails.application.load_tasks unless Rake::Task.task_defined?('lms_credentials:purge') + end + + let(:task) { Rake::Task['lms_credentials:purge'] } + + # Rake tasks only run once per process by default; re-enable between examples. + after { task.reenable } + + def create_credential(email, lms_name) + lms = Lms.find_or_create_by(lms_name: lms_name) { |l| l.use_auth_token = true } + user = User.create!(email: email, canvas_uid: email) + user.lms_credentials.create!( + lms: lms, + token: 'token', + refresh_token: 'refresh_token', + expire_time: 1.hour.from_now + ) + end + + it 'deletes canvas credentials by default and leaves other LMS credentials alone' do + canvas_credential = create_credential('canvas-user@example.com', 'canvas') + other_credential = create_credential('other-user@example.com', 'other_lms') + + expect { task.invoke }.to output(/Deleted 1 canvas credential/).to_stdout + + expect(LmsCredential.exists?(canvas_credential.id)).to be false + expect(LmsCredential.exists?(other_credential.id)).to be true + end + + it 'purges the given LMS when a name is passed' do + canvas_credential = create_credential('canvas-user2@example.com', 'canvas') + other_credential = create_credential('other-user2@example.com', 'other_lms') + + expect { task.invoke('other_lms') }.to output(/Deleted 1 other_lms credential/).to_stdout + + expect(LmsCredential.exists?(other_credential.id)).to be false + expect(LmsCredential.exists?(canvas_credential.id)).to be true + end +end diff --git a/spec/tasks/notifications_rake_spec.rb b/spec/tasks/notifications_rake_spec.rb index 638d1c4e..f8d2c818 100644 --- a/spec/tasks/notifications_rake_spec.rb +++ b/spec/tasks/notifications_rake_spec.rb @@ -3,7 +3,7 @@ RSpec.describe 'notifications:send_pending_digests' do # rubocop:disable RSpec/DescribeClass before(:all) do - Rails.application.load_tasks + Rails.application.load_tasks unless Rake::Task.task_defined?('notifications:send_pending_digests') end it 'invokes PendingRequestsNotificationJob with valid frequency' do diff --git a/utils/canvas_sandbox/README.md b/utils/canvas_sandbox/README.md new file mode 100644 index 00000000..59f18207 --- /dev/null +++ b/utils/canvas_sandbox/README.md @@ -0,0 +1,57 @@ +# Canvas sandbox approval testing + +Rerunnable scripts for exercising Flextensions' extension-approval flows +against a real Canvas instance (by default the UC Berkeley sandbox, +`https://ucberkeleysandbox.instructure.com`). + +Both scripts read the Canvas API token from the `CANVAS_TOKEN` environment +variable. **Never commit or hardcode a token.** + +## 1. `setup_test_course.rb` — create/refresh the Canvas test data + +```sh +CANVAS_TOKEN= ruby utils/canvas_sandbox/setup_test_course.rb +``` + +Ensures the test course (default `CANVAS_COURSE_ID=146`, +[Flextensions Test Course](https://ucberkeleysandbox.instructure.com/courses/146)) +contains one of each assignment type Flextensions must support, with due dates +~30 days in the future (rerun any time the dates go stale): + +- [Flextensions Test Assignment (regular)](https://ucberkeleysandbox.instructure.com/courses/146/assignments/208) +- [Flextensions Test Quiz (classic)](https://ucberkeleysandbox.instructure.com/courses/146/assignments/207) +- [Flextensions Test Discussion (graded)](https://ucberkeleysandbox.instructure.com/courses/146/assignments/206) + +The course roster's `*.example.com` students are used as test students. + +## 2. `test_approval_flows.rb` — end-to-end approval scenarios + +```sh +CANVAS_TOKEN= CANVAS_URL=https://ucberkeleysandbox.instructure.com \ + bin/rails runner utils/canvas_sandbox/test_approval_flows.rb +``` + +Runs the real application code (`Request#process_created_request`, +`Request#approve`, `CanvasFacade#provision_extension`) against the sandbox, +using a local development database seeded to mirror the test course. For each +of the three assignment types it verifies: + +- **AUTO** — a request within `auto_approve_days` is auto-approved and the + override appears in Canvas with the right due date. +- **MANUAL** — a request outside `auto_approve_days` stays pending, then is + approved with a staff user's facade and appears in Canvas. +- **GROUP** — a second student with the same extension length joins the first + student's override group instead of getting a new override. + +It then replays two production failure modes and asserts auto-approval still +succeeds by falling back to another staff user: + +- **NOCREDS** — the course's first staff member has no Canvas credentials at + all (a TA synced from the Canvas roster who never logged into Flextensions). +- **STALE** — the preferred staff member has credentials, but they are expired + and cannot be refreshed (Canvas revokes refresh tokens that go unused for + months). The refresh attempt is made for real against the Canvas OAuth + endpoint and fails, exercising the staff failover path end to end. + +The script cleans up the Canvas overrides it created; pass `KEEP_OVERRIDES=1` +to leave them in place for manual inspection. diff --git a/utils/canvas_sandbox/setup_test_course.rb b/utils/canvas_sandbox/setup_test_course.rb new file mode 100644 index 00000000..fc211ca6 --- /dev/null +++ b/utils/canvas_sandbox/setup_test_course.rb @@ -0,0 +1,124 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Ensures a Canvas sandbox course has one of each assignment type Flextensions +# must support: a regular assignment, a classic quiz, and a graded discussion. +# Safe to rerun: existing items (matched by name) are reused and their due +# dates pushed into the future. +# +# Usage: +# CANVAS_TOKEN= [CANVAS_URL=https://ucberkeleysandbox.instructure.com] \ +# [CANVAS_COURSE_ID=146] ruby utils/canvas_sandbox/setup_test_course.rb +# +# Prints the Canvas URLs of everything it created or found. + +require 'faraday' +require 'json' +require 'time' + +CANVAS_URL = ENV.fetch('CANVAS_URL', 'https://ucberkeleysandbox.instructure.com') +COURSE_ID = ENV.fetch('CANVAS_COURSE_ID', '146') +TOKEN = ENV.fetch('CANVAS_TOKEN') { abort 'Set CANVAS_TOKEN (do not hardcode tokens).' } + +DUE_AT = (Time.now.utc + (30 * 24 * 3600)).strftime('%Y-%m-%dT23:59:00Z') +LOCK_AT = (Time.now.utc + (33 * 24 * 3600)).strftime('%Y-%m-%dT23:59:00Z') + +CONN = Faraday.new(url: "#{CANVAS_URL}/api/v1") do |f| + f.request :json + f.response :json + f.headers['Authorization'] = "Bearer #{TOKEN}" +end + +def find_assignment_by_name(name) + assignments = CONN.get("courses/#{COURSE_ID}/assignments", { per_page: 100, search_term: name }).body + return nil unless assignments.is_a?(Array) + + assignments.find { |a| a['name'] == name } +end + +def update_assignment_dates(assignment_id) + CONN.put("courses/#{COURSE_ID}/assignments/#{assignment_id}", { + assignment: { due_at: DUE_AT, lock_at: LOCK_AT, published: true } + }).body +end + +def ensure_regular_assignment(name) + existing = find_assignment_by_name(name) + return update_assignment_dates(existing['id']) if existing + + CONN.post("courses/#{COURSE_ID}/assignments", { + assignment: { + name: name, + submission_types: [ 'online_text_entry' ], + points_possible: 10, + due_at: DUE_AT, + lock_at: LOCK_AT, + published: true + } + }).body +end + +# A classic quiz is created through the quizzes endpoint; Canvas creates a +# shadow assignment (assignment_id) which is what Flextensions syncs and +# provisions overrides against. +def ensure_classic_quiz(name) + existing = find_assignment_by_name(name) + return update_assignment_dates(existing['id']) if existing + + quiz = CONN.post("courses/#{COURSE_ID}/quizzes", { + quiz: { + title: name, + quiz_type: 'assignment', + due_at: DUE_AT, + lock_at: LOCK_AT, + published: true + } + }).body + abort "Failed to create classic quiz: #{quiz}" unless quiz.is_a?(Hash) && quiz['id'] + + find_assignment_by_name(name) +end + +# A graded discussion is created through the discussion_topics endpoint with +# an assignment payload; Canvas again creates a shadow assignment. +def ensure_graded_discussion(name) + existing = find_assignment_by_name(name) + return update_assignment_dates(existing['id']) if existing + + topic = CONN.post("courses/#{COURSE_ID}/discussion_topics", { + title: name, + message: 'Created by Flextensions approval testing (utils/canvas_sandbox).', + published: true, + assignment: { points_possible: 5, due_at: DUE_AT, lock_at: LOCK_AT } + }).body + abort "Failed to create graded discussion: #{topic}" unless topic.is_a?(Hash) && topic['id'] + + find_assignment_by_name(name) +end + +course = CONN.get("courses/#{COURSE_ID}").body +abort "Cannot access course #{COURSE_ID}: #{course}" unless course.is_a?(Hash) && course['id'] + +puts "Course: #{course['name']} — #{CANVAS_URL}/courses/#{COURSE_ID}" +puts "Due date used: #{DUE_AT} (lock/late date #{LOCK_AT})" +puts + +{ + 'Flextensions Test Assignment (regular)' => method(:ensure_regular_assignment), + 'Flextensions Test Quiz (classic)' => method(:ensure_classic_quiz), + 'Flextensions Test Discussion (graded)' => method(:ensure_graded_discussion) +}.each do |name, ensure_fn| + assignment = ensure_fn.call(name) + if assignment.is_a?(Hash) && assignment['id'] + puts format('%-42s assignment_id=%-5s %s', name, assignment['id'], + "#{CANVAS_URL}/courses/#{COURSE_ID}/assignments/#{assignment['id']}") + else + puts format('%-42s FAILED: %s', name, assignment.inspect) + end +end + +students = CONN.get("courses/#{COURSE_ID}/users", { 'enrollment_type[]' => 'student', per_page: 100 }).body +puts +puts "Students available as test roster (#{students.size}):" +students.first(8).each { |s| puts " #{s['id']}: #{s['name']} <#{s['email']}>" } +puts ' ...' if students.size > 8 diff --git a/utils/canvas_sandbox/test_approval_flows.rb b/utils/canvas_sandbox/test_approval_flows.rb new file mode 100644 index 00000000..133c1954 --- /dev/null +++ b/utils/canvas_sandbox/test_approval_flows.rb @@ -0,0 +1,270 @@ +# frozen_string_literal: true + +# End-to-end exercise of Flextensions' approval flows against a real Canvas +# sandbox, using the actual application code (Request#process_created_request, +# Request#approve, CanvasFacade#provision_extension). +# +# For each assignment type (regular assignment, classic quiz, graded +# discussion) it runs: +# 1. AUTO — a request inside auto_approve_days, submitted via the same +# entry point the controller uses; must end up approved, +# auto_approved, and visible as an override in Canvas. +# 2. MANUAL — a request outside auto_approve_days; must stay pending, then +# be approved with the staff user's facade and appear in Canvas. +# 3. GROUP — a second student requesting the same extension length; must +# join the first student's override group, not get a new one. +# Finally it reruns AUTO for two production failure modes: +# NOCREDS — the course's first staff member has no Canvas credentials at all +# (a TA synced from the roster who never logged into Flextensions). +# STALE — the preferred staff member HAS credentials, but they are expired +# and cannot be refreshed (Canvas revokes refresh tokens that go +# unused for months); approval must fail over to another staff +# user whose token still works. +# +# Usage (never hardcode the token): +# CANVAS_TOKEN= CANVAS_URL=https://ucberkeleysandbox.instructure.com \ +# bin/rails runner utils/canvas_sandbox/test_approval_flows.rb +# +# Options via ENV: +# CANVAS_COURSE_ID sandbox course to test against (default 146) +# KEEP_OVERRIDES=1 leave the Canvas overrides in place for inspection +# +# Rerunnable: it deletes the local test requests it created and (unless +# KEEP_OVERRIDES=1) the Canvas overrides it provisioned. + +abort 'Run in development, not production.' unless Rails.env.development? + +TOKEN = ENV.fetch('CANVAS_TOKEN') { abort 'Set CANVAS_TOKEN.' } +CANVAS_COURSE_ID = ENV.fetch('CANVAS_COURSE_ID', '146') + +TEST_ASSIGNMENT_NAMES = [ + 'Flextensions Test Assignment (regular)', + 'Flextensions Test Quiz (classic)', + 'Flextensions Test Discussion (graded)' +].freeze + +# Sandbox students (course 146 roster). auto/manual/group are exercised on +# every assignment type; no_creds_staff only on the first. +STUDENTS = { + auto: { canvas_uid: '237', name: 'Paul Gregory', email: 'paul.gregory86@example.com' }, + manual: { canvas_uid: '238', name: 'Kent Hansen', email: 'kent.hansen98@example.com' }, + group: { canvas_uid: '239', name: 'Becky Hayes', email: 'becky.hayes43@example.com' }, + no_creds: { canvas_uid: '240', name: 'Sherri Johnson', email: 'sherri.johnson53@example.com' }, + stale: { canvas_uid: '241', name: 'Chad Carter', email: 'chad.carter67@example.com' } +}.freeze + +STAFF = { canvas_uid: '136', name: 'Michael Ball', email: 'ball@berkeley.edu' }.freeze +NO_CREDS_STAFF = { canvas_uid: '999136', name: 'No Creds TA', email: 'no-creds-ta@example.com' }.freeze +STALE_STAFF = { canvas_uid: '999137', name: 'Stale Creds TA', email: 'stale-creds-ta@example.com' }.freeze + +@results = [] +@created_override_keys = [] # [canvas_course_id, external_assignment_id] + +def record(scenario, assignment, ok, detail) + @results << { scenario: scenario, assignment: assignment, ok: ok, detail: detail } + puts format('%-8s %-40s %s %s', scenario, assignment, ok ? 'PASS' : 'FAIL', detail) +end + +def facade + @facade ||= CanvasFacade.new(TOKEN) +end + +def find_or_create_user(attrs) + user = User.find_or_initialize_by(canvas_uid: attrs[:canvas_uid]) + user.update!(name: attrs[:name], email: attrs[:email]) + user +end + +def give_canvas_credentials(user) + cred = user.lms_credentials.find_or_initialize_by(lms: Lms.CANVAS_LMS) + cred.update!(token: TOKEN, refresh_token: 'none', expire_time: 1.year.from_now) +end + +def enroll(user, course, role) + Enrollment.find_or_create_by!(user: user, course: course, role: role) +end + +def canvas_override_for(assignment, student) + overrides = facade.get_all_assignment_overrides(CANVAS_COURSE_ID, assignment.external_assignment_id) + return nil unless overrides.is_a?(Array) + + overrides.find { |o| o['student_ids']&.map(&:to_s)&.include?(student.canvas_uid) } +end + +def build_request(course, assignment, student, days) + # Rerunnable: drop any previous test request for this student+assignment. + Request.where(user: student, assignment: assignment).delete_all + course.requests.create!( + assignment: assignment, + user: student, + reason: 'Sandbox approval-flow test', + requested_due_date: assignment.due_date + days.days + ) +end + +def verify_in_canvas(scenario, assignment, student, request) + override = canvas_override_for(assignment, student) + return record(scenario, assignment.name, false, 'no override found in Canvas') unless override + + @created_override_keys << [ assignment, student ] + due_ok = Time.zone.parse(override['due_at']) == request.requested_due_date + record(scenario, assignment.name, due_ok, + "override #{override['id']} '#{override['title']}' due=#{override['due_at']}" \ + "#{due_ok ? '' : " (expected #{request.requested_due_date.iso8601})"}") + override +end + +# --------------------------------------------------------------------------- +# Local seed mirroring the sandbox course +# --------------------------------------------------------------------------- +lms = Lms.find_or_initialize_by(id: CANVAS_LMS_ID) +lms.update!(lms_name: 'Canvas', lms_base_url: CanvasFacade::CANVAS_URL, use_auth_token: true) +staff = find_or_create_user(STAFF) +give_canvas_credentials(staff) +students = STUDENTS.transform_values { |attrs| find_or_create_user(attrs) } + +course = Course.find_or_initialize_by(canvas_id: CANVAS_COURSE_ID) +course.update!(course_name: 'Flextensions Test Course (sandbox)', course_code: 'FLEXTEST') +course_to_lms = CourseToLms.find_or_create_by!(course: course, lms_id: CANVAS_LMS_ID) do |ctl| + ctl.external_course_id = CANVAS_COURSE_ID +end + +settings = CourseSettings.find_or_initialize_by(course: course) +settings.update!(enable_extensions: true, auto_approve_days: 3, max_auto_approve: 0, + enable_emails: false, min_hours_before_deadline: 0) + +# Make the credentialed staff user the course's only staff enrollment. +Enrollment.where(course: course).delete_all +enroll(staff, course, 'teacher') +students.each_value { |s| enroll(s, course, 'student') } + +# Sync the three test assignments from Canvas through the real facade. +assignments = facade.get_all_assignments(CANVAS_COURSE_ID).select { |a| TEST_ASSIGNMENT_NAMES.include?(a.name) } +abort "Expected 3 test assignments in Canvas, found #{assignments.size}. Run setup_test_course.rb first." unless assignments.size == 3 + +local_assignments = assignments.map do |a| + local = Assignment.find_or_initialize_by(course_to_lms: course_to_lms, external_assignment_id: a.id.to_s) + local.update!(name: a.name, due_date: a.due_date, late_due_date: a.late_due_date, enabled: true) + local +end + +puts "Testing against #{CanvasFacade::CANVAS_URL}/courses/#{CANVAS_COURSE_ID}" +puts + +# --------------------------------------------------------------------------- +# Scenarios, per assignment type +# --------------------------------------------------------------------------- +local_assignments.each do |assignment| + # AUTO: inside auto_approve_days, entering through the code path the + # controller uses on create. + request = build_request(course, assignment, students[:auto], 2) + request.process_created_request(students[:auto]) + request.reload + if request.status == 'approved' && request.auto_approved + verify_in_canvas('AUTO', assignment, students[:auto], request) + else + record('AUTO', assignment.name, false, + "status=#{request.status} auto_approved=#{request.auto_approved} errors=#{request.errors.full_messages}") + end + + # MANUAL: outside auto_approve_days -> stays pending -> staff approves. + request = build_request(course, assignment, students[:manual], 10) + request.process_created_request(students[:manual]) + request.reload + if request.status != 'pending' + record('MANUAL', assignment.name, false, "expected pending after create, got #{request.status}") + elsif request.approve(CanvasFacade.from_user(staff), staff) && request.reload.status == 'approved' + verify_in_canvas('MANUAL', assignment, students[:manual], request) + else + record('MANUAL', assignment.name, false, "approve failed: #{request.errors.full_messages}") + end + + # GROUP: same extension length as AUTO student -> should join their override. + request = build_request(course, assignment, students[:group], 2) + request.process_created_request(students[:group]) + request.reload + if request.status == 'approved' + override = canvas_override_for(assignment, students[:group]) + shared = override && override['student_ids'].map(&:to_s).include?(students[:auto].canvas_uid) + @created_override_keys << [ assignment, students[:group] ] if override + record('GROUP', assignment.name, !!shared, + override ? "override #{override['id']} students=#{override['student_ids']}" : 'no override found') + else + record('GROUP', assignment.name, false, "status=#{request.status} errors=#{request.errors.full_messages}") + end +end + +# --------------------------------------------------------------------------- +# Production repro: first-enrolled staff member has no Canvas credentials +# (e.g. a TA synced from the Canvas roster who never logged into Flextensions). +# --------------------------------------------------------------------------- +no_creds_staff = find_or_create_user(NO_CREDS_STAFF) +no_creds_staff.lms_credentials.destroy_all +Enrollment.where(course: course, role: Enrollment.staff_roles).delete_all +enroll(no_creds_staff, course, 'ta') # enrolled first -> arbitrary .first picks them +enroll(staff, course, 'teacher') + +assignment = local_assignments.first +request = build_request(course, assignment, students[:no_creds], 2) +request.process_created_request(students[:no_creds]) +request.reload +if request.status == 'approved' + verify_in_canvas('NOCREDS', assignment, students[:no_creds], request) +else + record('NOCREDS', assignment.name, false, + "request stayed '#{request.status}' — auto-approval silently skipped " \ + 'because the first staff enrollment has no Canvas credentials') +end + +# --------------------------------------------------------------------------- +# Production repro: the preferred staff member HAS credentials but they are +# expired and unrefreshable (Canvas revoked the idle refresh token). The +# credential is newer than the working teacher's, so the failover order tries +# the stale one first; approval must fall through to the working teacher. +# --------------------------------------------------------------------------- +stale_staff = find_or_create_user(STALE_STAFF) +stale_staff.lms_credentials.destroy_all +stale_staff.lms_credentials.create!( + lms: Lms.CANVAS_LMS, + token: 'expired-and-unrefreshable', + refresh_token: 'revoked-refresh-token', + expire_time: 1.minute.from_now +) +enroll(stale_staff, course, 'ta') + +request = build_request(course, assignment, students[:stale], 2) +request.process_created_request(students[:stale]) +request.reload +if request.status == 'approved' + verify_in_canvas('STALE', assignment, students[:stale], request) +else + record('STALE', assignment.name, false, + "request stayed '#{request.status}' — failover past the stale staff credential did not happen " \ + "(breakdown: #{request.auto_approval_breakdown.inspect})") +end + +# --------------------------------------------------------------------------- +# Cleanup +# --------------------------------------------------------------------------- +if ENV['KEEP_OVERRIDES'] == '1' + puts "\nKEEP_OVERRIDES=1 — leaving Canvas overrides in place." +else + puts "\nCleaning up Canvas overrides created by this run..." + @created_override_keys.uniq.each do |assignment, student| + override = canvas_override_for(assignment, student) + next unless override + + remaining = override['student_ids'].map(&:to_s) - [ student.canvas_uid ] + if remaining.empty? + facade.delete_assignment_override(CANVAS_COURSE_ID, assignment.external_assignment_id, override['id']) + else + facade.update_assignment_override(CANVAS_COURSE_ID, assignment.external_assignment_id, override['id'], + remaining, override['title'], override['due_at'], + override['unlock_at'], override['lock_at']) + end + end +end + +failures = @results.count { |r| !r[:ok] } +puts format("\n%d scenarios, %d failed", @results.size, failures) +exit(failures.zero? ? 0 : 1) From 47a3598f1ddd214fb005aede916395f53a1d5714 Mon Sep 17 00:00:00 2001 From: Michael Ball Date: Sat, 4 Jul 2026 18:48:06 +0000 Subject: [PATCH 30/32] Create test Lms rows with explicit ids to avoid pkey sequence collisions The suite seeds Lms rows (ids 1 and 2) with explicit ids, which leaves the lmss id sequence pointing at 1. Creating an Lms without an id in the purge task spec then collides with the seeded rows on a fresh CI database. Find the seeded row case-insensitively and create any extra LMS with an explicit id instead of relying on the sequence. Co-Authored-By: Claude Fable 5 --- spec/tasks/lms_credentials_rake_spec.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/spec/tasks/lms_credentials_rake_spec.rb b/spec/tasks/lms_credentials_rake_spec.rb index dcfcf7ce..30400301 100644 --- a/spec/tasks/lms_credentials_rake_spec.rb +++ b/spec/tasks/lms_credentials_rake_spec.rb @@ -12,7 +12,11 @@ after { task.reenable } def create_credential(email, lms_name) - lms = Lms.find_or_create_by(lms_name: lms_name) { |l| l.use_auth_token = true } + # The seeds loaded in rails_helper insert Lms rows with explicit ids, which + # leaves the lmss id sequence behind the existing rows — so create with an + # explicit id too rather than relying on the sequence. + lms = Lms.find_by('LOWER(lms_name) = ?', lms_name.downcase) || + Lms.create!(id: Lms.maximum(:id).to_i + 1, lms_name: lms_name, use_auth_token: true) user = User.create!(email: email, canvas_uid: email) user.lms_credentials.create!( lms: lms, From f53284c6a1fc5d869e82ff6411badcabd1f15d64 Mon Sep 17 00:00:00 2001 From: Michael Ball Date: Sat, 4 Jul 2026 16:49:31 -0700 Subject: [PATCH 31/32] Delete old duplicate migrations file --- .../20260704000001_rename_user_to_courses_to_enrollments.rb | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 db/migrate/20260704000001_rename_user_to_courses_to_enrollments.rb diff --git a/db/migrate/20260704000001_rename_user_to_courses_to_enrollments.rb b/db/migrate/20260704000001_rename_user_to_courses_to_enrollments.rb deleted file mode 100644 index a01ecc54..00000000 --- a/db/migrate/20260704000001_rename_user_to_courses_to_enrollments.rb +++ /dev/null @@ -1,5 +0,0 @@ -class RenameUserToCoursesToEnrollments < ActiveRecord::Migration[7.2] - def change - safety_assured { rename_table :user_to_courses, :enrollments } - end -end From f00b46a24accbbf159bdf06c5ef2465462d13c73 Mon Sep 17 00:00:00 2001 From: Michael Ball Date: Sat, 4 Jul 2026 23:51:03 +0000 Subject: [PATCH 32/32] Drop duplicate RenameUserToCoursesToEnrollments migration Main renumbered the rename migration to 20260701000001 and removed the 20260704000001 file; after merging main this branch carried both, and ActiveRecord's duplicate-migration-name check aborted every spec run. Delete the stale copy so the migration set matches main. Co-Authored-By: Claude Fable 5 --- .../20260704000001_rename_user_to_courses_to_enrollments.rb | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 db/migrate/20260704000001_rename_user_to_courses_to_enrollments.rb diff --git a/db/migrate/20260704000001_rename_user_to_courses_to_enrollments.rb b/db/migrate/20260704000001_rename_user_to_courses_to_enrollments.rb deleted file mode 100644 index a01ecc54..00000000 --- a/db/migrate/20260704000001_rename_user_to_courses_to_enrollments.rb +++ /dev/null @@ -1,5 +0,0 @@ -class RenameUserToCoursesToEnrollments < ActiveRecord::Migration[7.2] - def change - safety_assured { rename_table :user_to_courses, :enrollments } - end -end