diff --git a/.tool-versions b/.tool-versions
index 057186bf..053cba7f 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -1 +1 @@
-ruby 3.3.8
+ruby 3.3
diff --git a/Gemfile b/Gemfile
index d0c7ea6e..7987e6e5 100644
--- a/Gemfile
+++ b/Gemfile
@@ -32,6 +32,9 @@ gem 'tzinfo-data', platforms: %i[windows jruby]
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false
+# Loads environment variables from .env (local dev/test only, not Heroku)
+gem 'dotenv-rails', groups: [ :development, :test ]
+
# Alternative Canvas API. We probably don't need this.
# Verify instances of `LMS::Canvas`
gem 'lms-api'
@@ -60,6 +63,9 @@ gem 'strong_migrations'
# Logging Customization
gem 'lograge'
+# Environment variable management
+gem 'dotenv-rails', require: 'dotenv/load'
+
# Use Active Storage for file uploads [https://guides.rubyonrails.org/active_storage_overview.html]
# gem "activestorage", "~> 7.0.0"
@@ -70,6 +76,7 @@ gem 'lograge'
#
gem 'blazer'
gem 'hypershield'
+gem 'good_job', '~> 4.0'
#### Frontend related tools
# The original asset pipeline for Rails [https://github.com/rails/sprockets-rails]
diff --git a/Gemfile.lock b/Gemfile.lock
index 92b2be3c..59aee7fd 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -185,10 +185,16 @@ GEM
diff-lcs (1.6.2)
docile (1.4.1)
domain_name (0.6.20240107)
+ dotenv (3.2.0)
+ dotenv-rails (3.2.0)
+ dotenv (= 3.2.0)
+ railties (>= 6.1)
drb (2.2.3)
dumb_delegator (1.1.0)
erb (6.0.2)
erubi (1.13.1)
+ et-orbi (1.4.0)
+ tzinfo
factory_bot (6.5.6)
activesupport (>= 6.1.0)
factory_bot_rails (6.5.1)
@@ -215,8 +221,18 @@ GEM
sassc (~> 2.0)
formatador (1.2.3)
reline
+ fugit (1.12.1)
+ et-orbi (~> 1.4)
+ raabro (~> 1.4)
globalid (1.3.0)
activesupport (>= 6.1)
+ good_job (4.18.2)
+ activejob (>= 6.1.0)
+ activerecord (>= 6.1.0)
+ concurrent-ruby (>= 1.3.1)
+ fugit (>= 1.11.0)
+ railties (>= 6.1.0)
+ thor (>= 1.0.0)
guard (2.20.1)
formatador (>= 0.2.4)
listen (>= 2.7, < 4.0)
@@ -382,6 +398,7 @@ GEM
public_suffix (7.0.5)
puma (7.2.0)
nio4r (~> 2.0)
+ raabro (1.4.0)
racc (1.8.1)
rack (3.2.6)
rack-protection (4.2.1)
@@ -624,10 +641,12 @@ DEPENDENCIES
cucumber-rails
database_cleaner-active_record
debug
+ dotenv-rails
factory_bot_rails
faraday
faraday-cookie_jar
font-awesome-sass
+ good_job (~> 4.0)
guard-rspec
hypershield
importmap-rails
diff --git a/Procfile b/Procfile
new file mode 100644
index 00000000..581b1f10
--- /dev/null
+++ b/Procfile
@@ -0,0 +1,2 @@
+web: bundle exec rails server -p $PORT
+worker: bundle exec good_job start
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 4a5b7441..293c7912 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -85,8 +85,6 @@ $secondary: $california-gold;
@import "custom_bootstrap";
@import "font-awesome";
-@import "datatables/dataTables.bootstrap5.css";
-@import "datatables/responsive.bootstrap5.css";
@each $name, $value in $colors {
.bg-#{$name} {
diff --git a/app/controllers/course_settings_controller.rb b/app/controllers/course_settings_controller.rb
index 4a2cfe07..10116d88 100644
--- a/app/controllers/course_settings_controller.rb
+++ b/app/controllers/course_settings_controller.rb
@@ -72,7 +72,9 @@ def course_settings_params
:email_subject,
:email_template,
:enable_slack_webhook_url,
- :slack_webhook_url
+ :slack_webhook_url,
+ :pending_notification_frequency,
+ :pending_notification_email
)
end
diff --git a/app/controllers/courses_controller.rb b/app/controllers/courses_controller.rb
index c5b0d03f..9422df6d 100644
--- a/app/controllers/courses_controller.rb
+++ b/app/controllers/courses_controller.rb
@@ -1,11 +1,11 @@
class CoursesController < ApplicationController
before_action :authenticate_user
- before_action :set_course, only: %i[show edit sync_assignments sync_enrollments enrollments delete]
+ before_action :set_course, only: %i[show edit sync_assignments sync_enrollments sync_status bulk_update_assignments enrollments delete]
before_action :set_pending_request_count
before_action :determine_user_role
def index
- teacher_courses = UserToCourse.includes(:course).where(user: @user, role: %w[teacher ta])
+ teacher_courses = UserToCourse.includes(:course).where(user: @user, role: UserToCourse.staff_roles)
@teacher_courses_by_semester = group_by_semester(teacher_courses)
# Only show courses to students if extensions are enabled at the course level
@@ -44,16 +44,14 @@ def new
@courses = Course.fetch_courses(token)
flash[:alert] = 'No courses found.' if @courses.empty?
- # Collect unique semester names from Canvas term data for the filter dropdown
@semesters = @courses.filter_map { |c| c.dig('term', 'name') }.uniq.sort
@selected_semester = params[:semester]
- teacher_enrollment_types = %w[teacher ta]
# 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, teacher_enrollment_types, existing_canvas_ids)
- @courses_student = filter_courses(@courses, [ 'student' ], existing_canvas_ids)
+ @courses_teacher = filter_courses(@courses, UserToCourse.staff_roles, existing_canvas_ids)
+ @courses_student = filter_courses(@courses, [ UserToCourse::STUDENT_ROLE ], existing_canvas_ids)
if @selected_semester.present?
@courses_teacher = filter_by_semester(@courses_teacher, @selected_semester)
@@ -68,7 +66,7 @@ def edit
def create
token = @user.lms_credentials.first.token
- filter_courses(Course.fetch_courses(token), %w[teacher ta])
+ filter_courses(Course.fetch_courses(token), UserToCourse.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.'
@@ -81,6 +79,17 @@ def sync_assignments
render json: { message: 'Assignments synced successfully.' }, status: :ok
end
+ def bulk_update_assignments
+ return render json: { error: 'Course not found.' }, status: :not_found unless @course
+ return render json: { error: 'You do not have permission.' }, status: :forbidden unless @role == 'instructor'
+
+ enabled = ActiveModel::Type::Boolean.new.cast(params[:enabled])
+ scope = Assignment.where(course_to_lms_id: CourseToLms.where(course_id: @course.id).select(:id))
+ scope = scope.where.not(due_date: nil) if enabled
+ scope.update_all(enabled: enabled) # rubocop:disable Rails/SkipsModelValidations
+ render json: { success: true }, status: :ok
+ end
+
def sync_enrollments
return render json: { error: 'Course not found.' }, status: :not_found unless @course
return render json: { error: 'You do not have permission.' }, status: :forbidden unless @is_course_admin
@@ -89,12 +98,25 @@ def sync_enrollments
render json: { message: 'Users synced successfully.' }, status: :ok
end
+ def sync_status
+ return render json: { error: 'You do not have permission.' }, status: :forbidden unless @is_course_admin
+
+ course_to_lms = @course.course_to_lms(1)
+ return render json: { error: 'LMS connection not found.' }, status: :not_found unless course_to_lms
+
+ render json: {
+ roster_synced_at: course_to_lms.recent_roster_sync&.dig('synced_at'),
+ assignments_synced_at: course_to_lms.recent_assignment_sync&.dig('synced_at')
+ }, status: :ok
+ end
+
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)
@is_course_admin = @course.course_admin?(@user)
+ @approved_late_days = Request.total_approved_late_days_by_user(@course)
end
def delete
@@ -133,7 +155,6 @@ def group_by_semester(user_to_courses)
sorted_semesters.map { |semester| [ semester, grouped[semester] ] }
end
- # Filters Canvas API course hashes by their term name
def filter_by_semester(courses, semester)
courses.select { |c| c.dig('term', 'name') == semester }
end
@@ -146,7 +167,9 @@ def filter_courses(courses, roles, exclude_ids = [])
courses = courses - missing_enrollments - courses.select { |course| exclude_ids.include?(course['id'].to_s) }
return [] if courses.empty?
- courses.select { |course| course['enrollments'].any? { |e| roles.include?(e['type']) } }
+ courses.select do |course|
+ course['enrollments'].any? { |enrollment| roles.include?(UserToCourse.role_from_canvas_enrollment(enrollment)) }
+ end
end
def course_data_for_sync
diff --git a/app/controllers/requests_controller.rb b/app/controllers/requests_controller.rb
index 463f809d..d1a93503 100644
--- a/app/controllers/requests_controller.rb
+++ b/app/controllers/requests_controller.rb
@@ -32,6 +32,7 @@ def index
def show
@assignment = @request.assignment
@number_of_days = @request.calculate_days_difference if @request.requested_due_date.present? && @assignment&.due_date.present?
+ @student_enrollment = @course.user_to_courses.find_by(user: @request.user) if @role == 'instructor'
render_role_based_view
end
diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb
index 81fd512c..55d156fc 100644
--- a/app/controllers/session_controller.rb
+++ b/app/controllers/session_controller.rb
@@ -53,15 +53,25 @@ def omniauth_callback
'email' => auth.info.email
}
creds = auth.credentials # an OmniAuth::AuthHash
+
+ # 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: creds.refresh_token,
- expires_at: creds.expires_at
+ refresh_token: refresh_token,
+ expires_at: expires_at
)
# Persist / update the user just like `create`
- find_or_create_user(user_data, access_token)
+ user = find_or_create_user(user_data, access_token)
+
+ # Auto-enroll developer login users in test courses
+ if auth.provider == 'developer'
+ ensure_developer_test_enrollments(user)
+ end
redirect_to courses_path, notice: "Logged in! Welcome, #{user_data['name']}!"
rescue StandardError => e
@@ -79,6 +89,18 @@ def destroy
private
+ def ensure_developer_test_enrollments(user)
+ # Find the test course
+ test_course = Course.find_by(course_code: 'DEV101')
+
+ # 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'
+ end
+ end
+ end
+
# TODO: Refactor.
def find_or_create_user(user_data, auth_token)
auth_token.token
@@ -102,6 +124,8 @@ def find_or_create_user(user_data, auth_token)
# Store user ID in session for authentication
session[:username] = user.name
session[:user_id] = user.canvas_uid
+
+ user
end
# TODO: Move this to a Canvas API libarary or user service
@@ -111,14 +135,14 @@ def update_user_credential(user, token)
user.lms_credentials.first.update(
token: token.token,
refresh_token: token.refresh_token,
- expire_time: Time.zone.at(token.expires_at)
+ expire_time: Time.zone.at(token.expires_at || 30.days.from_now.to_i)
)
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)
+ expire_time: Time.zone.at(token.expires_at || 30.days.from_now.to_i)
)
end
end
diff --git a/app/controllers/user_to_courses_controller.rb b/app/controllers/user_to_courses_controller.rb
index b07d7336..72bd98c5 100644
--- a/app/controllers/user_to_courses_controller.rb
+++ b/app/controllers/user_to_courses_controller.rb
@@ -1,25 +1,57 @@
class UserToCoursesController < ApplicationController
- before_action :authenticate_user
+ before_action :authenticate_user!
before_action :set_course
- before_action :ensure_course_admin
+ before_action :set_enrollment
+ before_action :authorize_instructor!
def toggle_allow_extended_requests
- @enrollment = @course.user_to_courses.find(params[:id])
-
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_content
+ render json: {
+ success: false,
+ errors: @enrollment.errors.full_messages,
+ redirect_to: courses_path
+ }, status: :unprocessable_content
+ end
+ end
+
+ def update_notes
+ if @enrollment.update(notes: params[:notes])
+ render json: { success: true, notes: @enrollment.notes }, status: :ok
+ else
+ render json: { success: false, error: @enrollment.errors.full_messages.to_sentence }, status: :unprocessable_content
end
end
private
- def ensure_course_admin
- enrollment = @course.user_to_courses.find_by(user: @user)
- return if enrollment&.course_admin?
+ 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
- render json: { error: 'You must be an instructor or Lead TA.', redirect_to: course_path(@course) }, status: :forbidden
+ def set_enrollment
+ @enrollment = @course.user_to_courses.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
end
end
diff --git a/app/facades/canvas_facade.rb b/app/facades/canvas_facade.rb
index 6d437afd..19e38689 100644
--- a/app/facades/canvas_facade.rb
+++ b/app/facades/canvas_facade.rb
@@ -1,4 +1,5 @@
require 'date'
+require 'cgi'
require 'faraday'
require 'json'
require 'ostruct'
@@ -8,6 +9,9 @@ class CanvasFacade < LmsFacade
class CanvasAPIError < LmsFacade::LmsAPIError; end
CANVAS_URL = ENV.fetch('CANVAS_URL', nil)
+ CANVAS_CUSTOM_COURSE_ROLES = {
+ UserToCourse::LEAD_TA_ROLE => 'Lead TA'
+ }.freeze
# Canvas instances can scope the flextensions developer key.
# There must be one scope for each endpoint we can use.
@@ -161,10 +165,10 @@ def get_all_course_users(course, role = nil)
# sigh, manually construct query string until we tweak Faraday middleware
# to include :url_encoded, then use `'enrollment_type[]' : list_or_string`
query_string = 'per_page=100'
- query_string += "&enrollment_type[]=#{role}" if role.is_a?(String) && role.present?
+ query_string += "{role_query_param(role)}" if role.is_a?(String) && role.present?
if role.is_a?(Array) && role.present? # rubocop:disable Style/IfUnlessModifier
- query_string += role.map { |r| "&enrollment_type[]=#{r}" }.join
+ query_string += role.map { |r| "{role_query_param(r)}" }.join
end
depaginate_response(@canvas_conn.get("courses/#{course.canvas_id}/users?#{query_string}"))
@@ -189,6 +193,17 @@ def get_instructor_courses
teacher_courses + ta_courses
end
+ def role_query_param(role)
+ normalized_role = UserToCourse.normalize_role(role)
+ canvas_course_role = CANVAS_CUSTOM_COURSE_ROLES[normalized_role]
+
+ if canvas_course_role
+ "enrollment_role=#{CGI.escape(canvas_course_role)}"
+ else
+ "enrollment_type[]=#{CGI.escape(normalized_role)}"
+ end
+ end
+
##
# Gets a specified course that the authorized user has access to.
#
diff --git a/app/javascript/controllers/assignment_controller.js b/app/javascript/controllers/assignment_controller.js
index 8112cfb5..cf86090e 100644
--- a/app/javascript/controllers/assignment_controller.js
+++ b/app/javascript/controllers/assignment_controller.js
@@ -1,12 +1,13 @@
import { Controller } from "@hotwired/stimulus"
import DataTable from "datatables.net-bs5";
+import { pollUntilDone } from "controllers/sync_poller";
import "datatables.net-responsive";
import "datatables.net-responsive-bs5";
// Connects to data-controller="assignment"
export default class extends Controller {
- static targets = ["checkbox"]
- static values = { courseId: Number }
+ static targets = ["checkbox", "syncBtn", "syncLabel", "syncSpinner"]
+ static values = { courseId: Number, bulkUrl: String }
connect() {
this.checkboxTargets.forEach((checkbox) => {
@@ -64,31 +65,84 @@ export default class extends Controller {
}
}
- sync(event) {
+ async bulkUpdate(enabled) {
+ const url = this.bulkUrlValue;
+ const token = document.querySelector('meta[name="csrf-token"]').content;
+
+ try {
+ const response = await fetch(url, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRF-Token": token,
+ },
+ body: JSON.stringify({ enabled }),
+ });
+
+ if (!response.ok) throw new Error("Failed to update assignments.");
+
+ const dt = DataTable.isDataTable('#assignments-table')
+ ? new DataTable('#assignments-table')
+ : null;
+ if (dt) {
+ dt.rows().nodes().each((node) => {
+ const cb = node.querySelector('.assignment-enabled-switch');
+ if (cb) cb.checked = enabled;
+ });
+ } else {
+ document.querySelectorAll(".assignment-enabled-switch").forEach((cb) => {
+ cb.checked = enabled;
+ });
+ }
+ } catch (error) {
+ flash("alert", error.message || "An error occurred.");
+ }
+ }
+
+ enableAll(event) {
+ const button = event.currentTarget;
+ button.disabled = true;
+ this.bulkUpdate(true).finally(() => { button.disabled = false; });
+ }
+
+ disableAll(event) {
const button = event.currentTarget;
button.disabled = true;
+ this.bulkUpdate(false).finally(() => { button.disabled = false; });
+ }
+
+ async sync() {
+ const button = this.syncBtnTarget;
+ const label = this.syncLabelTarget;
+ const spinner = this.syncSpinnerTarget;
const courseId = this.courseIdValue;
const token = document.querySelector('meta[name="csrf-token"]').content;
- fetch(`/courses/${courseId}/sync_assignments`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- "X-CSRF-Token": token,
- },
- })
- .then((response) => {
- if (!response.ok) {
- throw new Error("Failed to sync assignments.");
- }
- return response.json();
- })
- .then((data) => {
- flash("notice", data.message || "Assignments synced successfully.");
- location.reload();
- })
- .catch((error) => {
- flash("alert", error.message || "An error occurred while syncing assignments.");
- location.reload();
+
+ button.disabled = true;
+ label.textContent = "Syncing...";
+ spinner.classList.remove("d-none");
+
+ try {
+ const statusBefore = await fetch(`/courses/${courseId}/sync_status`).then(r => r.json());
+ const beforeTs = statusBefore.assignments_synced_at;
+
+ const response = await fetch(`/courses/${courseId}/sync_assignments`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": token },
});
+
+ if (!response.ok) throw new Error(`Failed to sync assignments. ${response.status}`);
+
+ await pollUntilDone(courseId, "assignments_synced_at", beforeTs);
+
+ flash("notice", "Assignments synced successfully.");
+ location.reload();
+ } catch (error) {
+ flash("alert", error.message || "An error occurred while syncing assignments.");
+ button.disabled = false;
+ label.textContent = "Sync Assignments";
+ spinner.classList.add("d-none");
+ }
}
+
}
diff --git a/app/javascript/controllers/course_settings_controller.js b/app/javascript/controllers/course_settings_controller.js
index 19a4ff03..7ada1020 100644
--- a/app/javascript/controllers/course_settings_controller.js
+++ b/app/javascript/controllers/course_settings_controller.js
@@ -1,12 +1,13 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
- static targets = ["emailField", "tab", "gradescopeField", "slackWebhookField"];
+ static targets = ["emailField", "tab", "gradescopeField", "slackWebhookField", "pendingNotificationEmail"];
connect() {
this.toggleEmailFields();
this.toggleSlackWebhookField();
this.toggleGradescopeFields();
+ this.togglePendingNotificationEmail();
const gradescopeToggle = document.getElementById('enable-gradescope');
if (gradescopeToggle) {
@@ -53,6 +54,15 @@ export default class extends Controller {
}
}
+ togglePendingNotificationEmail() {
+ const frequencySelect = document.getElementById('pending-notification-frequency');
+ const emailField = document.getElementById('pending-notification-email');
+
+ if (frequencySelect && emailField) {
+ emailField.disabled = !frequencySelect.value;
+ }
+ }
+
updateUrlParam(event) {
const tabName = event.currentTarget.dataset.tab;
const url = new URL(window.location);
diff --git a/app/javascript/controllers/enrollments_controller.js b/app/javascript/controllers/enrollments_controller.js
index cd03aecb..2d72d058 100644
--- a/app/javascript/controllers/enrollments_controller.js
+++ b/app/javascript/controllers/enrollments_controller.js
@@ -1,17 +1,18 @@
import { Controller } from "@hotwired/stimulus";
import DataTable from "datatables.net-bs5";
+import { pollUntilDone } from "controllers/sync_poller";
import "datatables.net-responsive";
import "datatables.net-responsive-bs5";
export default class extends Controller {
- static targets = ["checkbox"]
+ static targets = ["checkbox", "syncBtn", "syncLabel", "syncSpinner"]
static values = { courseId: Number }
connect() {
if (!DataTable.isDataTable('#enrollments-table')) {
// Define a custom sorting function for the Role column
DataTable.ext.type.order['role-pre'] = function (data) {
- const rolePriority = { teacher: 4, ta: 2, student: 3 };
+ const rolePriority = { teacher: 4, leadta: 3, "lead ta": 3, ta: 2, student: 1 };
if (typeof data !== 'string') {
data = String(data).trim();
}
@@ -26,9 +27,9 @@ export default class extends Controller {
responsive: true,
pageLength: 500,
lengthMenu: [[-1, 25, 50, 100, 500], ["All", 25, 50, 100, 500]],
- columns: document.querySelectorAll('#enrollments-table thead th').length === 5
- ? [null, null, null, { orderDataType: 'role-pre' }, null]
- : [null, null, null, { orderDataType: 'role-pre' }],
+ columns: document.querySelectorAll('#enrollments-table thead th').length === 6
+ ? [null, null, null, { orderDataType: 'role-pre' }, null, null]
+ : [null, null, null, { orderDataType: 'role-pre' }, null],
order: [[3, 'des'], [0, 'asc']] // Sort Role first, then Name
});
}
@@ -76,30 +77,39 @@ export default class extends Controller {
window.dispatchEvent(new CustomEvent('flash', { detail: { type: type, message: message } }));
}
- sync() {
- const button = event.currentTarget;
- button.disabled = true;
+ async sync() {
+ const button = this.syncBtnTarget;
+ const label = this.syncLabelTarget;
+ const spinner = this.syncSpinnerTarget;
const courseId = this.courseIdValue;
- const token = document.querySelector('meta[name="csrf-token"]').content; fetch(`/courses/${courseId}/sync_enrollments`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- "X-CSRF-Token": token,
- },
- })
- .then((response) => {
- if (!response.ok) {
- throw new Error(`Failed to sync enrollments. ${response.status} - ${response.statusText}`);
- }
- return response.json();
- })
- .then((data) => {
- flash("notice", data.message || "Enrollments synced successfully.");
+ const token = document.querySelector('meta[name="csrf-token"]')?.content || '';
+
+ button.disabled = true;
+ label.textContent = "Syncing...";
+ spinner.classList.remove("d-none");
+
+ try {
+ // Capture timestamp before sync so we can detect when job finishes
+ const statusBefore = await fetch(`/courses/${courseId}/sync_status`).then(r => r.json());
+ const beforeTs = statusBefore.roster_synced_at;
+
+ const response = await fetch(`/courses/${courseId}/sync_enrollments`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": token },
+ });
+
+ if (!response.ok) throw new Error(`Failed to sync enrollments. ${response.status}`);
+
+ await pollUntilDone(courseId, "roster_synced_at", beforeTs);
+
+ flash("notice", "Enrollments synced successfully.");
location.reload();
- })
- .catch((error) => {
+ } catch (error) {
flash("alert", error.message || "An error occurred while syncing enrollments.");
- location.reload();
- });
- }
+ button.disabled = false;
+ label.textContent = "Sync Enrollments";
+ spinner.classList.add("d-none");
+ }
+ }
+
}
diff --git a/app/javascript/controllers/student_notes_controller.js b/app/javascript/controllers/student_notes_controller.js
new file mode 100644
index 00000000..07ddd0ca
--- /dev/null
+++ b/app/javascript/controllers/student_notes_controller.js
@@ -0,0 +1,66 @@
+import { Controller } from "@hotwired/stimulus";
+
+export default class extends Controller {
+ static targets = ["display", "form", "textarea", "content"];
+ static values = { url: String };
+
+ edit() {
+ this.displayTarget.classList.add("d-none");
+ this.formTarget.classList.remove("d-none");
+ this.textareaTarget.focus();
+ }
+
+ cancel() {
+ this.formTarget.classList.add("d-none");
+ this.displayTarget.classList.remove("d-none");
+ }
+
+ async save() {
+ const notes = this.textareaTarget.value;
+ const token = document.querySelector('meta[name="csrf-token"]').content;
+
+ try {
+ const response = await fetch(this.urlValue, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRF-Token": token,
+ },
+ body: JSON.stringify({ notes }),
+ });
+
+ const data = await response.json();
+
+ if (response.ok && data.success) {
+ if (notes.trim()) {
+ // Convert newlines to
tags and set as HTML
+ const escaped = notes
+ .replace(/&/g, "&")
+ .replace(//g, ">");
+ const html = `
diff --git a/app/views/requests/instructor_show.html.erb b/app/views/requests/instructor_show.html.erb
index 16141ade..79c556d1 100644
--- a/app/views/requests/instructor_show.html.erb
+++ b/app/views/requests/instructor_show.html.erb
@@ -117,6 +117,29 @@
<% end %>
+
+ <% if @student_enrollment.present? %>
+
+
Staff Notes for <%= @request.user.name %>
+
+
+ <%= @student_enrollment.notes.present? ? simple_format(@student_enrollment.notes) : content_tag(:span, 'No notes yet.', class: 'text-muted') %>
+
+
+
+
+
+ <% end %>
+
<% if @request.status == 'pending' %>
diff --git a/config/application.rb b/config/application.rb
index e298613f..9c4003f8 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -40,6 +40,7 @@ class Application < Rails::Application
config.active_record.default_timezone = :utc
config.time_zone = 'Pacific Time (US & Canada)'
config.generators.system_tests = nil
+ config.active_job.queue_adapter = :good_job
# We do not require the master key and insetad use environment variables
# Review .env.example for required variables.
diff --git a/config/environments/test.rb b/config/environments/test.rb
index c9f0436f..b43794c5 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -66,6 +66,11 @@
# Raise error when a before_action's only/except options reference missing actions
config.action_controller.raise_on_missing_callback_actions = true
+ # Disable GoodJob's in-process worker so enqueued jobs do not execute during tests.
+ # Without this, GoodJob runs jobs in background threads, causing sync operations to
+ # complete before Capybara can observe transient UI states (spinner, disabled button).
+ config.good_job.execution_mode = :external
+
# Set up default encryption keys for the test environment
config.active_record.encryption.primary_key = 'test-primary-key-1234567890abcdef'
config.active_record.encryption.deterministic_key = 'test-deterministic-key-1234567890abcdef'
diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb
new file mode 100644
index 00000000..3c827270
--- /dev/null
+++ b/config/initializers/good_job.rb
@@ -0,0 +1,17 @@
+Rails.application.config.to_prepare do
+ GoodJob::ApplicationController.class_eval do
+ def current_user
+ @current_user ||= User.find_by(canvas_uid: session[:user_id])
+ end
+
+ before_action :require_admin
+
+ def require_admin
+ if current_user.nil?
+ redirect_to '/', alert: 'You must be logged in.'
+ elsif !current_user.admin?
+ redirect_to '/', alert: 'You are not authorized to view this page.'
+ end
+ end
+ end
+end
diff --git a/config/initializers/lograge.rb b/config/initializers/lograge.rb
index 25af3a01..d469bdc2 100644
--- a/config/initializers/lograge.rb
+++ b/config/initializers/lograge.rb
@@ -25,7 +25,7 @@
config.lograge.custom_payload do |controller|
{
request_id: controller.request.uuid,
- user_id: controller.current_user.try(:id)
+ user_id: controller.respond_to?(:current_user) ? controller.current_user.try(:id) : nil
}
end
diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb
index 29445eea..bfffa61e 100644
--- a/config/initializers/omniauth.rb
+++ b/config/initializers/omniauth.rb
@@ -26,7 +26,8 @@ def build_access_token
client_options: {
site: ENV['CANVAS_URL'],
authorize_url: "/login/oauth2/auth?scope=#{encoded_scopes}"
- }
+ },
+ redirect_uri: "#{ENV['CANVAS_REDIRECT_URI']}/auth/canvas/callback"
end
# OmniAuth.config.before_request_phase do |env|
diff --git a/config/routes.rb b/config/routes.rb
index 2a0448d9..bf30bf52 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -30,6 +30,8 @@
member do
post :sync_assignments
post :sync_enrollments
+ patch :bulk_update_assignments
+ get :sync_status
get :enrollments
delete :delete
end
@@ -50,6 +52,7 @@
resources :user_to_courses, only: [] do
member do
patch :toggle_allow_extended_requests
+ patch :update_notes
end
end
resource :form_setting, only: [:edit, :update]
@@ -68,4 +71,5 @@
# This is protected by `require_admin` via blazer.yml
mount Blazer::Engine, at: "admin/blazer"
+ mount GoodJob::Engine, at: "admin/good_job"
end
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/migrate/20260404000001_add_notes_to_user_to_courses.rb b/db/migrate/20260404000001_add_notes_to_user_to_courses.rb
new file mode 100644
index 00000000..e2d569ec
--- /dev/null
+++ b/db/migrate/20260404000001_add_notes_to_user_to_courses.rb
@@ -0,0 +1,5 @@
+class AddNotesToUserToCourses < ActiveRecord::Migration[7.2]
+ def change
+ add_column :user_to_courses, :notes, :text
+ end
+end
diff --git a/db/migrate/20260406175234_add_pending_notification_to_course_settings.rb b/db/migrate/20260406175234_add_pending_notification_to_course_settings.rb
new file mode 100644
index 00000000..fceca5e9
--- /dev/null
+++ b/db/migrate/20260406175234_add_pending_notification_to_course_settings.rb
@@ -0,0 +1,10 @@
+class AddPendingNotificationToCourseSettings < ActiveRecord::Migration[7.2]
+ def change
+ safety_assured do
+ change_table :course_settings, bulk: true do |t|
+ t.string :pending_notification_frequency, default: nil
+ t.string :pending_notification_email, default: nil
+ end
+ end
+ end
+end
diff --git a/db/migrate/20260407004259_create_good_jobs.rb b/db/migrate/20260407004259_create_good_jobs.rb
new file mode 100644
index 00000000..e2e88bfb
--- /dev/null
+++ b/db/migrate/20260407004259_create_good_jobs.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+class CreateGoodJobs < ActiveRecord::Migration[7.2]
+ def change
+ # Uncomment for Postgres v12 or earlier to enable gen_random_uuid() support
+ # enable_extension 'pgcrypto'
+
+ create_table :good_jobs, id: :uuid do |t|
+ t.text :queue_name
+ t.integer :priority
+ t.jsonb :serialized_params
+ t.datetime :scheduled_at
+ t.datetime :performed_at
+ t.datetime :finished_at
+ t.text :error
+
+ t.timestamps
+
+ t.uuid :active_job_id
+ t.text :concurrency_key
+ t.text :cron_key
+ t.uuid :retried_good_job_id
+ t.datetime :cron_at
+
+ t.uuid :batch_id
+ t.uuid :batch_callback_id
+
+ t.boolean :is_discrete
+ t.integer :executions_count
+ t.text :job_class
+ t.integer :error_event, limit: 2
+ t.text :labels, array: true
+ t.uuid :locked_by_id
+ t.datetime :locked_at
+ end
+
+ create_table :good_job_batches, id: :uuid do |t|
+ t.timestamps
+ t.text :description
+ t.jsonb :serialized_properties
+ t.text :on_finish
+ t.text :on_success
+ t.text :on_discard
+ t.text :callback_queue_name
+ t.integer :callback_priority
+ t.datetime :enqueued_at
+ t.datetime :discarded_at
+ t.datetime :finished_at
+ t.datetime :jobs_finished_at
+ end
+
+ create_table :good_job_executions, id: :uuid do |t|
+ t.timestamps
+
+ t.uuid :active_job_id, null: false
+ t.text :job_class
+ t.text :queue_name
+ t.jsonb :serialized_params
+ t.datetime :scheduled_at
+ t.datetime :finished_at
+ t.text :error
+ t.integer :error_event, limit: 2
+ t.text :error_backtrace, array: true
+ t.uuid :process_id
+ t.interval :duration
+ end
+
+ create_table :good_job_processes, id: :uuid do |t|
+ t.timestamps
+ t.jsonb :state
+ t.integer :lock_type, limit: 2
+ end
+
+ create_table :good_job_settings, id: :uuid do |t|
+ t.timestamps
+ t.text :key
+ t.jsonb :value
+ t.index :key, unique: true
+ end
+
+ add_index :good_jobs, :scheduled_at, where: "(finished_at IS NULL)", name: :index_good_jobs_on_scheduled_at
+ add_index :good_jobs, [ :queue_name, :scheduled_at ], where: "(finished_at IS NULL)", name: :index_good_jobs_on_queue_name_and_scheduled_at
+ add_index :good_jobs, [ :active_job_id, :created_at ], name: :index_good_jobs_on_active_job_id_and_created_at
+ add_index :good_jobs, :concurrency_key, where: "(finished_at IS NULL)", name: :index_good_jobs_on_concurrency_key_when_unfinished
+ add_index :good_jobs, [ :concurrency_key, :created_at ], name: :index_good_jobs_on_concurrency_key_and_created_at
+ add_index :good_jobs, [ :cron_key, :created_at ], where: "(cron_key IS NOT NULL)", name: :index_good_jobs_on_cron_key_and_created_at_cond
+ add_index :good_jobs, [ :cron_key, :cron_at ], where: "(cron_key IS NOT NULL)", unique: true, name: :index_good_jobs_on_cron_key_and_cron_at_cond
+ add_index :good_jobs, [ :finished_at ], where: "finished_at IS NOT NULL", name: :index_good_jobs_jobs_on_finished_at_only
+ add_index :good_jobs, [ :priority, :created_at ], order: { priority: "DESC NULLS LAST", created_at: :asc },
+ where: "finished_at IS NULL", name: :index_good_jobs_jobs_on_priority_created_at_when_unfinished
+ add_index :good_jobs, [ :priority, :created_at ], order: { priority: "ASC NULLS LAST", created_at: :asc },
+ where: "finished_at IS NULL", name: :index_good_job_jobs_for_candidate_lookup
+ add_index :good_jobs, [ :batch_id ], where: "batch_id IS NOT NULL"
+ add_index :good_jobs, [ :batch_callback_id ], where: "batch_callback_id IS NOT NULL"
+ add_index :good_jobs, :job_class, name: :index_good_jobs_on_job_class
+ add_index :good_jobs, :labels, using: :gin, where: "(labels IS NOT NULL)", name: :index_good_jobs_on_labels
+
+ add_index :good_job_executions, [ :active_job_id, :created_at ], name: :index_good_job_executions_on_active_job_id_and_created_at
+ add_index :good_jobs, [ :priority, :scheduled_at ], order: { priority: "ASC NULLS LAST", scheduled_at: :asc },
+ where: "finished_at IS NULL AND locked_by_id IS NULL", name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked
+ add_index :good_jobs, :locked_by_id,
+ where: "locked_by_id IS NOT NULL", name: "index_good_jobs_on_locked_by_id"
+ add_index :good_job_executions, [ :process_id, :created_at ], name: :index_good_job_executions_on_process_id_and_created_at
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 848df19c..b562d7bf 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,9 +10,10 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2026_03_06_000001) do
+ActiveRecord::Schema[7.2].define(version: 2026_04_07_004259) do
create_schema "hypershield"
+
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -105,6 +106,8 @@
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.index ["course_id"], name: "index_course_settings_on_course_id"
end
@@ -161,9 +164,99 @@
t.index ["course_id"], name: "index_form_settings_on_course_id"
end
+ create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.text "description"
+ t.jsonb "serialized_properties"
+ t.text "on_finish"
+ t.text "on_success"
+ t.text "on_discard"
+ t.text "callback_queue_name"
+ t.integer "callback_priority"
+ t.datetime "enqueued_at"
+ t.datetime "discarded_at"
+ t.datetime "finished_at"
+ t.datetime "jobs_finished_at"
+ end
+
+ create_table "good_job_executions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.uuid "active_job_id", null: false
+ t.text "job_class"
+ t.text "queue_name"
+ t.jsonb "serialized_params"
+ t.datetime "scheduled_at"
+ t.datetime "finished_at"
+ t.text "error"
+ t.integer "error_event", limit: 2
+ t.text "error_backtrace", array: true
+ t.uuid "process_id"
+ t.interval "duration"
+ t.index ["active_job_id", "created_at"], name: "index_good_job_executions_on_active_job_id_and_created_at"
+ t.index ["process_id", "created_at"], name: "index_good_job_executions_on_process_id_and_created_at"
+ end
+
+ create_table "good_job_processes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.jsonb "state"
+ t.integer "lock_type", limit: 2
+ end
+
+ create_table "good_job_settings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.text "key"
+ t.jsonb "value"
+ t.index ["key"], name: "index_good_job_settings_on_key", unique: true
+ end
+
+ create_table "good_jobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.text "queue_name"
+ t.integer "priority"
+ t.jsonb "serialized_params"
+ t.datetime "scheduled_at"
+ t.datetime "performed_at"
+ t.datetime "finished_at"
+ t.text "error"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.uuid "active_job_id"
+ t.text "concurrency_key"
+ t.text "cron_key"
+ t.uuid "retried_good_job_id"
+ t.datetime "cron_at"
+ t.uuid "batch_id"
+ t.uuid "batch_callback_id"
+ t.boolean "is_discrete"
+ t.integer "executions_count"
+ t.text "job_class"
+ t.integer "error_event", limit: 2
+ t.text "labels", array: true
+ t.uuid "locked_by_id"
+ t.datetime "locked_at"
+ t.index ["active_job_id", "created_at"], name: "index_good_jobs_on_active_job_id_and_created_at"
+ t.index ["batch_callback_id"], name: "index_good_jobs_on_batch_callback_id", where: "(batch_callback_id IS NOT NULL)"
+ t.index ["batch_id"], name: "index_good_jobs_on_batch_id", where: "(batch_id IS NOT NULL)"
+ t.index ["concurrency_key", "created_at"], name: "index_good_jobs_on_concurrency_key_and_created_at"
+ t.index ["concurrency_key"], name: "index_good_jobs_on_concurrency_key_when_unfinished", where: "(finished_at IS NULL)"
+ t.index ["cron_key", "created_at"], name: "index_good_jobs_on_cron_key_and_created_at_cond", where: "(cron_key IS NOT NULL)"
+ t.index ["cron_key", "cron_at"], name: "index_good_jobs_on_cron_key_and_cron_at_cond", unique: true, where: "(cron_key IS NOT NULL)"
+ t.index ["finished_at"], name: "index_good_jobs_jobs_on_finished_at_only", where: "(finished_at IS NOT NULL)"
+ t.index ["job_class"], name: "index_good_jobs_on_job_class"
+ t.index ["labels"], name: "index_good_jobs_on_labels", where: "(labels IS NOT NULL)", using: :gin
+ t.index ["locked_by_id"], name: "index_good_jobs_on_locked_by_id", where: "(locked_by_id IS NOT NULL)"
+ t.index ["priority", "created_at"], name: "index_good_job_jobs_for_candidate_lookup", where: "(finished_at IS NULL)"
+ t.index ["priority", "created_at"], name: "index_good_jobs_jobs_on_priority_created_at_when_unfinished", order: { priority: "DESC NULLS LAST" }, where: "(finished_at IS NULL)"
+ t.index ["priority", "scheduled_at"], name: "index_good_jobs_on_priority_scheduled_at_unfinished_unlocked", where: "((finished_at IS NULL) AND (locked_by_id IS NULL))"
+ t.index ["queue_name", "scheduled_at"], name: "index_good_jobs_on_queue_name_and_scheduled_at", where: "(finished_at IS NULL)"
+ t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)"
+ end
+
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"
@@ -172,6 +265,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
@@ -213,6 +307,7 @@
t.datetime "updated_at", null: false
t.boolean "removed", default: false, null: false
t.boolean "allow_extended_requests", default: false, null: false
+ t.text "notes"
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
@@ -236,6 +331,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/db/seeds.rb b/db/seeds.rb
index 3b17f628..6457cbea 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -11,3 +11,65 @@
name: SystemUserService::AUTO_APPROVAL_NAME,
canvas_uid: SystemUserService::AUTO_APPROVAL_UID
)
+
+# Developer test users. you can use teacher or student
+if Rails.env.development?
+ # Teacher user for managing courses
+ teacher_user = User.find_or_create_by!(email: 'teacher@example.com') do |u|
+ u.name = 'Test Teacher'
+ u.admin = false
+ u.canvas_uid = 'teacher@example.com'
+ end
+
+ # Student user for requesting extensions
+ student_user = User.find_or_create_by!(email: 'student@example.com') do |u|
+ u.name = 'Test Student'
+ u.admin = false
+ u.canvas_uid = 'student@example.com'
+ end
+
+ # Create a single test course
+ test_course = Course.find_or_create_by!(course_code: 'DEV101') do |c|
+ c.course_name = 'Development Test Course'
+ c.canvas_id = 'dev-course-001'
+ end
+
+ # Link test course to Canvas LMS
+ CourseToLms.find_or_create_by!(course_id: test_course.id, lms_id: 1) do |ctl|
+ ctl.external_course_id = 'dev-course-001'
+ 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'
+ 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'
+ end
+
+ # Enable extensions for test course
+ CourseSettings.find_or_create_by!(course_id: test_course.id) do |cs|
+ cs.enable_extensions = true
+ end
+
+ # Create form settings for test course
+ FormSetting.find_or_create_by!(course_id: test_course.id) do |fs|
+ fs.documentation_disp = 'optional'
+ fs.custom_q1_disp = 'optional'
+ fs.custom_q2_disp = 'optional'
+ end
+
+ # Create sample assignments for test course
+ course_lms = CourseToLms.find_by(course_id: test_course.id, lms_id: 1)
+
+ if course_lms
+ Assignment.find_or_create_by!(course_to_lms_id: course_lms.id, external_assignment_id: 'dev-hw-1') do |a|
+ a.name = 'Homework'
+ a.due_date = 3.days.from_now
+ a.late_due_date = 10.days.from_now
+ a.enabled = true
+ end
+ end
+end
diff --git a/features/assignments.feature b/features/assignments.feature
index d6978f7f..61f07628 100644
--- a/features/assignments.feature
+++ b/features/assignments.feature
@@ -15,4 +15,17 @@ Feature: Course Assignments
Then I log in as a student
And I go to the Course page
Then I should not see "Homework 1"
- Then I should see "Homework 2"
\ No newline at end of file
+ Then I should see "Homework 2"
+
+ Scenario: Instructor sees the Sync Assignments button
+ Given I'm logged in as a teacher
+ When I go to the Course page
+ Then I should see a "Sync Assignments" button
+
+ @javascript
+ Scenario: Clicking Sync Assignments disables the button and shows a spinner
+ Given I'm logged in as a teacher
+ When I go to the Course page
+ And I click the "Sync Assignments" button
+ Then the "Sync Assignments" button should be disabled
+ And I should see a loading spinner
\ No newline at end of file
diff --git a/features/enrollments.feature b/features/enrollments.feature
index 8d16f577..4be7d1c0 100644
--- a/features/enrollments.feature
+++ b/features/enrollments.feature
@@ -35,3 +35,16 @@ Feature: Course Enrollments
And I click the name link for student "User 3"
Then I should be on the "Requests page" with param show_all=true
And the requests table search should be filtered
+
+ Scenario: Instructor sees the Sync Enrollments button
+ Given I'm logged in as a teacher
+ When I go to the Course Enrollments page
+ Then I should see a "Sync Enrollments" button
+
+ @javascript
+ Scenario: Clicking Sync Enrollments disables the button and shows a spinner
+ Given I'm logged in as a teacher
+ When I go to the Course Enrollments page
+ And I click the "Sync Enrollments" button
+ Then the "Sync Enrollments" button should be disabled
+ And I should see a loading spinner
diff --git a/features/navigation.feature b/features/navigation.feature
index d67167af..5ba8f581 100644
--- a/features/navigation.feature
+++ b/features/navigation.feature
@@ -40,3 +40,19 @@ Feature: Navigation
And I should see "Login with bCourses" in the navbar
And I should not see "Offerings" in the navbar
And I should not see "Logout" in the navbar
+
+ Scenario: Admin user sees admin tools in navbar dropdown
+ Given a course exists
+ And I am logged in as an admin
+ And I am on the "Courses page"
+ Then I should see "Admin Tools" in the navbar dropdown
+ And I should see "Dashboards" in the navbar dropdown
+ And I should see "Background Jobs" in the navbar dropdown
+
+ Scenario: Non-admin user does not see admin tools in navbar dropdown
+ Given a course exists
+ And I am logged in as a teacher
+ And I am on the "Courses page"
+ Then I should not see "Admin Tools" in the navbar dropdown
+ And I should not see "Dashboards" in the navbar dropdown
+ And I should not see "Background Jobs" in the navbar dropdown
diff --git a/features/step_definitions/custom_steps.rb b/features/step_definitions/custom_steps.rb
index 651f5c7a..d52a918d 100644
--- a/features/step_definitions/custom_steps.rb
+++ b/features/step_definitions/custom_steps.rb
@@ -14,6 +14,11 @@
end
end
+Given(/^I am logged in as an admin$/) do
+ user = create(:admin, email: 'admin@berkeley.edu', canvas_uid: 'canvas_uid_admin')
+ page.set_rack_session(user_id: user.canvas_uid, username: user.name)
+end
+
Given(/^(?:I am|I'm|I) (?:logged|log) in as a (teacher|ta|student)$/i) do |role|
emails = {
'teacher' => 'user1@berkeley.edu',
@@ -218,6 +223,41 @@
expect(page.current_path).to eq(expected_path)
end
+###################
+# SYNC UI #
+###################
+
+# Then I should see a "Sync Enrollments" button
+Then(/^I should see a "([^"]*)" button$/) do |label|
+ expect(page).to have_button(label)
+end
+
+# When I click the "Sync Enrollments" button
+When(/^I click the "([^"]*)" button$/) do |label|
+ # csrf_meta_tags renders nothing when allow_forgery_protection = false (test env).
+ # Inject a placeholder token so JS fetch handlers don't throw before disabling the button.
+ page.execute_script(<<~JS)
+ if (!document.querySelector('meta[name="csrf-token"]')) {
+ const m = document.createElement('meta');
+ m.name = 'csrf-token';
+ m.content = 'test-csrf-token';
+ document.head.appendChild(m);
+ }
+ JS
+ click_button(label)
+end
+
+# Then the "Sync Enrollments" button should be disabled
+# When sync starts, the label changes to "Syncing..." so we check for that text + disabled
+Then(/^the "([^"]*)" button should be disabled$/) do |_label|
+ expect(page).to have_button("Syncing...", disabled: true)
+end
+
+# Then I should see a loading spinner
+Then(/^I should see a loading spinner$/) do
+ expect(page).to have_css('.spinner-border:not(.d-none)', visible: true)
+end
+
Given(/^I deny the request for "([^"]*)"$/) do |assignment_name|
request = Request.joins(:assignment)
.find_by(assignments: { name: assignment_name }, status: 'pending')
diff --git a/features/step_definitions/navigation_steps.rb b/features/step_definitions/navigation_steps.rb
index 4711ac16..10685c40 100644
--- a/features/step_definitions/navigation_steps.rb
+++ b/features/step_definitions/navigation_steps.rb
@@ -14,6 +14,18 @@
end
end
+Then(/^I should see "(.*?)" in the navbar dropdown$/) do |text|
+ within('#user-dropdown-menu') do
+ expect(page).to have_content(text)
+ end
+end
+
+Then(/^I should not see "(.*?)" in the navbar dropdown$/) do |text|
+ within('#user-dropdown-menu') do
+ expect(page).not_to have_content(text)
+ end
+end
+
When(/^I navigate to any page other than the "(.*?)"$/) do |excluded_page|
# Currently included home and courses page
case excluded_page
diff --git a/features/step_definitions/student_notes_steps.rb b/features/step_definitions/student_notes_steps.rb
new file mode 100644
index 00000000..f3d28f9a
--- /dev/null
+++ b/features/step_definitions/student_notes_steps.rb
@@ -0,0 +1,14 @@
+# Navigate to the request show page for a given assignment
+When(/^I view the request for "([^"]*)"$/) do |assignment_name|
+ request = Request.joins(:assignment).find_by(assignments: { name: assignment_name })
+ raise "No request found for assignment #{assignment_name}" unless request
+
+ visit course_request_path(@course, request)
+end
+
+# Set notes on the student's enrollment in the course
+Given(/^the student for the course has notes "([^"]*)"$/) do |notes_text|
+ student = User.joins(:user_to_courses).find_by(user_to_courses: { course: @course, role: 'student' })
+ enrollment = UserToCourse.find_by(user: student, course: @course, role: 'student')
+ enrollment.update!(notes: notes_text)
+end
diff --git a/features/student_notes.feature b/features/student_notes.feature
new file mode 100644
index 00000000..b4c3cd6d
--- /dev/null
+++ b/features/student_notes.feature
@@ -0,0 +1,41 @@
+Feature: Student Notes
+
+Background:
+ Given a course exists
+ And I'm logged in as a teacher
+ When I go to the Course page
+ And I enable "Homework 1"
+
+Scenario: Teacher sees notes section on request detail page
+ Given I'm logged in as a student
+ And I go to the Course page
+ And I click New for "Homework 1" in the "assignments-table"
+ And I fill in "request[reason]" with "Need more time"
+ And I press "Submit Request"
+ Then I log in as a Teacher
+ And I view the request for "Homework 1"
+ Then I should see "Staff Notes for"
+ And I should see "No notes yet."
+
+Scenario: Teacher can save notes for a student
+ Given I'm logged in as a student
+ And I go to the Course page
+ And I click New for "Homework 1" in the "assignments-table"
+ And I fill in "request[reason]" with "Need more time"
+ And I press "Submit Request"
+ Then I log in as a Teacher
+ And the student for the course has notes "Student has DSP accommodations."
+ And I view the request for "Homework 1"
+ Then I should see "Student has DSP accommodations."
+
+Scenario: Student does not see staff notes on their request page
+ Given I'm logged in as a student
+ And I go to the Course page
+ And I click New for "Homework 1" in the "assignments-table"
+ And I fill in "request[reason]" with "Need more time"
+ And I press "Submit Request"
+ Given the student for the course has notes "Internal staff note"
+ And I go to the Requests page
+ And I click View for "Homework 1" in the "requests-table"
+ Then I should not see "Staff Notes for"
+ And I should not see "Internal staff note"
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/lib/tasks/notifications.rake b/lib/tasks/notifications.rake
new file mode 100644
index 00000000..978c5291
--- /dev/null
+++ b/lib/tasks/notifications.rake
@@ -0,0 +1,9 @@
+namespace :notifications do
+ desc 'Send pending request digest emails (usage: rake notifications:send_pending_digests[daily])'
+ task :send_pending_digests, [ :frequency ] => :environment do |_t, args|
+ frequency = args[:frequency]
+ abort 'Usage: rake notifications:send_pending_digests[daily|weekly]' unless %w[daily weekly].include?(frequency)
+
+ PendingRequestsNotificationJob.perform_now(frequency)
+ end
+end
diff --git a/public/datatables/dataTables.bootstrap5.css b/public/datatables/dataTables.bootstrap5.css
new file mode 100644
index 00000000..ed11e6ea
--- /dev/null
+++ b/public/datatables/dataTables.bootstrap5.css
@@ -0,0 +1,569 @@
+:root {
+ --dt-row-selected: 13, 110, 253;
+ --dt-row-selected-text: 255, 255, 255;
+ --dt-row-selected-link: 9, 10, 11;
+ --dt-row-stripe: 0, 0, 0;
+ --dt-row-hover: 0, 0, 0;
+ --dt-column-ordering: 0, 0, 0;
+ --dt-html-background: white;
+}
+:root.dark {
+ --dt-html-background: rgb(33, 37, 41);
+}
+
+table.dataTable td.dt-control {
+ text-align: center;
+ cursor: pointer;
+}
+table.dataTable td.dt-control:before {
+ display: inline-block;
+ box-sizing: border-box;
+ content: "";
+ border-top: 5px solid transparent;
+ border-left: 10px solid rgba(0, 0, 0, 0.5);
+ border-bottom: 5px solid transparent;
+ border-right: 0px solid transparent;
+}
+table.dataTable tr.dt-hasChild td.dt-control:before {
+ border-top: 10px solid rgba(0, 0, 0, 0.5);
+ border-left: 5px solid transparent;
+ border-bottom: 0px solid transparent;
+ border-right: 5px solid transparent;
+}
+table.dataTable tfoot:empty {
+ display: none;
+}
+
+html.dark table.dataTable td.dt-control:before,
+:root[data-bs-theme=dark] table.dataTable td.dt-control:before,
+:root[data-theme=dark] table.dataTable td.dt-control:before {
+ border-left-color: rgba(255, 255, 255, 0.5);
+}
+html.dark table.dataTable tr.dt-hasChild td.dt-control:before,
+:root[data-bs-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before,
+:root[data-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before {
+ border-top-color: rgba(255, 255, 255, 0.5);
+ border-left-color: transparent;
+}
+
+div.dt-scroll {
+ width: 100%;
+}
+
+div.dt-scroll-body thead tr,
+div.dt-scroll-body tfoot tr {
+ height: 0;
+}
+div.dt-scroll-body thead tr th, div.dt-scroll-body thead tr td,
+div.dt-scroll-body tfoot tr th,
+div.dt-scroll-body tfoot tr td {
+ height: 0 !important;
+ padding-top: 0px !important;
+ padding-bottom: 0px !important;
+ border-top-width: 0px !important;
+ border-bottom-width: 0px !important;
+}
+div.dt-scroll-body thead tr th div.dt-scroll-sizing, div.dt-scroll-body thead tr td div.dt-scroll-sizing,
+div.dt-scroll-body tfoot tr th div.dt-scroll-sizing,
+div.dt-scroll-body tfoot tr td div.dt-scroll-sizing {
+ height: 0 !important;
+ overflow: hidden !important;
+}
+
+table.dataTable thead > tr > th:active,
+table.dataTable thead > tr > td:active {
+ outline: none;
+}
+table.dataTable thead > tr > th.dt-orderable-asc span.dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order:before,
+table.dataTable thead > tr > td.dt-orderable-asc span.dt-column-order:before,
+table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order:before {
+ position: absolute;
+ display: block;
+ bottom: 50%;
+ content: "\25B2";
+ content: "\25B2"/"";
+}
+table.dataTable thead > tr > th.dt-orderable-desc span.dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order:after,
+table.dataTable thead > tr > td.dt-orderable-desc span.dt-column-order:after,
+table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order:after {
+ position: absolute;
+ display: block;
+ top: 50%;
+ content: "\25BC";
+ content: "\25BC"/"";
+}
+table.dataTable thead > tr > th.dt-orderable-asc span.dt-column-order, table.dataTable thead > tr > th.dt-orderable-desc span.dt-column-order, table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order,
+table.dataTable thead > tr > td.dt-orderable-asc span.dt-column-order,
+table.dataTable thead > tr > td.dt-orderable-desc span.dt-column-order,
+table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order,
+table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order {
+ position: relative;
+ width: 12px;
+ height: 20px;
+}
+table.dataTable thead > tr > th.dt-orderable-asc span.dt-column-order:before, table.dataTable thead > tr > th.dt-orderable-asc span.dt-column-order:after, table.dataTable thead > tr > th.dt-orderable-desc span.dt-column-order:before, table.dataTable thead > tr > th.dt-orderable-desc span.dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order:after,
+table.dataTable thead > tr > td.dt-orderable-asc span.dt-column-order:before,
+table.dataTable thead > tr > td.dt-orderable-asc span.dt-column-order:after,
+table.dataTable thead > tr > td.dt-orderable-desc span.dt-column-order:before,
+table.dataTable thead > tr > td.dt-orderable-desc span.dt-column-order:after,
+table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order:before,
+table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order:after,
+table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order:before,
+table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order:after {
+ left: 0;
+ opacity: 0.125;
+ line-height: 9px;
+ font-size: 0.8em;
+}
+table.dataTable thead > tr > th.dt-orderable-asc, table.dataTable thead > tr > th.dt-orderable-desc,
+table.dataTable thead > tr > td.dt-orderable-asc,
+table.dataTable thead > tr > td.dt-orderable-desc {
+ cursor: pointer;
+}
+table.dataTable thead > tr > th.dt-orderable-asc:hover, table.dataTable thead > tr > th.dt-orderable-desc:hover,
+table.dataTable thead > tr > td.dt-orderable-asc:hover,
+table.dataTable thead > tr > td.dt-orderable-desc:hover {
+ outline: 2px solid rgba(0, 0, 0, 0.05);
+ outline-offset: -2px;
+}
+table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order:after,
+table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order:before,
+table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order:after {
+ opacity: 0.6;
+}
+table.dataTable thead > tr > th.sorting_desc_disabled span.dt-column-order:after, table.dataTable thead > tr > th.sorting_asc_disabled span.dt-column-order:before,
+table.dataTable thead > tr > td.sorting_desc_disabled span.dt-column-order:after,
+table.dataTable thead > tr > td.sorting_asc_disabled span.dt-column-order:before {
+ display: none;
+}
+table.dataTable thead > tr > th:active,
+table.dataTable thead > tr > td:active {
+ outline: none;
+}
+
+table.dataTable thead > tr > th div.dt-column-header,
+table.dataTable thead > tr > th div.dt-column-footer,
+table.dataTable thead > tr > td div.dt-column-header,
+table.dataTable thead > tr > td div.dt-column-footer,
+table.dataTable tfoot > tr > th div.dt-column-header,
+table.dataTable tfoot > tr > th div.dt-column-footer,
+table.dataTable tfoot > tr > td div.dt-column-header,
+table.dataTable tfoot > tr > td div.dt-column-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 4px;
+}
+table.dataTable thead > tr > th div.dt-column-header span.dt-column-title,
+table.dataTable thead > tr > th div.dt-column-footer span.dt-column-title,
+table.dataTable thead > tr > td div.dt-column-header span.dt-column-title,
+table.dataTable thead > tr > td div.dt-column-footer span.dt-column-title,
+table.dataTable tfoot > tr > th div.dt-column-header span.dt-column-title,
+table.dataTable tfoot > tr > th div.dt-column-footer span.dt-column-title,
+table.dataTable tfoot > tr > td div.dt-column-header span.dt-column-title,
+table.dataTable tfoot > tr > td div.dt-column-footer span.dt-column-title {
+ flex-grow: 1;
+}
+table.dataTable thead > tr > th div.dt-column-header span.dt-column-title:empty,
+table.dataTable thead > tr > th div.dt-column-footer span.dt-column-title:empty,
+table.dataTable thead > tr > td div.dt-column-header span.dt-column-title:empty,
+table.dataTable thead > tr > td div.dt-column-footer span.dt-column-title:empty,
+table.dataTable tfoot > tr > th div.dt-column-header span.dt-column-title:empty,
+table.dataTable tfoot > tr > th div.dt-column-footer span.dt-column-title:empty,
+table.dataTable tfoot > tr > td div.dt-column-header span.dt-column-title:empty,
+table.dataTable tfoot > tr > td div.dt-column-footer span.dt-column-title:empty {
+ display: none;
+}
+
+div.dt-scroll-body > table.dataTable > thead > tr > th,
+div.dt-scroll-body > table.dataTable > thead > tr > td {
+ overflow: hidden;
+}
+
+:root.dark table.dataTable thead > tr > th.dt-orderable-asc:hover, :root.dark table.dataTable thead > tr > th.dt-orderable-desc:hover,
+:root.dark table.dataTable thead > tr > td.dt-orderable-asc:hover,
+:root.dark table.dataTable thead > tr > td.dt-orderable-desc:hover,
+:root[data-bs-theme=dark] table.dataTable thead > tr > th.dt-orderable-asc:hover,
+:root[data-bs-theme=dark] table.dataTable thead > tr > th.dt-orderable-desc:hover,
+:root[data-bs-theme=dark] table.dataTable thead > tr > td.dt-orderable-asc:hover,
+:root[data-bs-theme=dark] table.dataTable thead > tr > td.dt-orderable-desc:hover {
+ outline: 2px solid rgba(255, 255, 255, 0.05);
+}
+
+div.dt-processing {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 200px;
+ margin-left: -100px;
+ margin-top: -22px;
+ text-align: center;
+ padding: 2px;
+ z-index: 10;
+}
+div.dt-processing > div:last-child {
+ position: relative;
+ width: 80px;
+ height: 15px;
+ margin: 1em auto;
+}
+div.dt-processing > div:last-child > div {
+ position: absolute;
+ top: 0;
+ width: 13px;
+ height: 13px;
+ border-radius: 50%;
+ background: rgb(13, 110, 253);
+ background: rgb(var(--dt-row-selected));
+ animation-timing-function: cubic-bezier(0, 1, 1, 0);
+}
+div.dt-processing > div:last-child > div:nth-child(1) {
+ left: 8px;
+ animation: datatables-loader-1 0.6s infinite;
+}
+div.dt-processing > div:last-child > div:nth-child(2) {
+ left: 8px;
+ animation: datatables-loader-2 0.6s infinite;
+}
+div.dt-processing > div:last-child > div:nth-child(3) {
+ left: 32px;
+ animation: datatables-loader-2 0.6s infinite;
+}
+div.dt-processing > div:last-child > div:nth-child(4) {
+ left: 56px;
+ animation: datatables-loader-3 0.6s infinite;
+}
+
+@keyframes datatables-loader-1 {
+ 0% {
+ transform: scale(0);
+ }
+ 100% {
+ transform: scale(1);
+ }
+}
+@keyframes datatables-loader-3 {
+ 0% {
+ transform: scale(1);
+ }
+ 100% {
+ transform: scale(0);
+ }
+}
+@keyframes datatables-loader-2 {
+ 0% {
+ transform: translate(0, 0);
+ }
+ 100% {
+ transform: translate(24px, 0);
+ }
+}
+table.dataTable.nowrap th, table.dataTable.nowrap td {
+ white-space: nowrap;
+}
+table.dataTable th,
+table.dataTable td {
+ box-sizing: border-box;
+}
+table.dataTable th.dt-type-numeric, table.dataTable th.dt-type-date,
+table.dataTable td.dt-type-numeric,
+table.dataTable td.dt-type-date {
+ text-align: right;
+}
+table.dataTable th.dt-type-numeric div.dt-column-header,
+table.dataTable th.dt-type-numeric div.dt-column-footer, table.dataTable th.dt-type-date div.dt-column-header,
+table.dataTable th.dt-type-date div.dt-column-footer,
+table.dataTable td.dt-type-numeric div.dt-column-header,
+table.dataTable td.dt-type-numeric div.dt-column-footer,
+table.dataTable td.dt-type-date div.dt-column-header,
+table.dataTable td.dt-type-date div.dt-column-footer {
+ flex-direction: row-reverse;
+}
+table.dataTable th.dt-left,
+table.dataTable td.dt-left {
+ text-align: left;
+}
+table.dataTable th.dt-center,
+table.dataTable td.dt-center {
+ text-align: center;
+}
+table.dataTable th.dt-right,
+table.dataTable td.dt-right {
+ text-align: right;
+}
+table.dataTable th.dt-right div.dt-column-header,
+table.dataTable th.dt-right div.dt-column-footer,
+table.dataTable td.dt-right div.dt-column-header,
+table.dataTable td.dt-right div.dt-column-footer {
+ flex-direction: row-reverse;
+}
+table.dataTable th.dt-justify,
+table.dataTable td.dt-justify {
+ text-align: justify;
+}
+table.dataTable th.dt-nowrap,
+table.dataTable td.dt-nowrap {
+ white-space: nowrap;
+}
+table.dataTable th.dt-empty,
+table.dataTable td.dt-empty {
+ text-align: center;
+ vertical-align: top;
+}
+table.dataTable thead th,
+table.dataTable thead td,
+table.dataTable tfoot th,
+table.dataTable tfoot td {
+ text-align: left;
+}
+table.dataTable thead th.dt-head-left,
+table.dataTable thead td.dt-head-left,
+table.dataTable tfoot th.dt-head-left,
+table.dataTable tfoot td.dt-head-left {
+ text-align: left;
+}
+table.dataTable thead th.dt-head-center,
+table.dataTable thead td.dt-head-center,
+table.dataTable tfoot th.dt-head-center,
+table.dataTable tfoot td.dt-head-center {
+ text-align: center;
+}
+table.dataTable thead th.dt-head-right,
+table.dataTable thead td.dt-head-right,
+table.dataTable tfoot th.dt-head-right,
+table.dataTable tfoot td.dt-head-right {
+ text-align: right;
+}
+table.dataTable thead th.dt-head-right div.dt-column-header,
+table.dataTable thead th.dt-head-right div.dt-column-footer,
+table.dataTable thead td.dt-head-right div.dt-column-header,
+table.dataTable thead td.dt-head-right div.dt-column-footer,
+table.dataTable tfoot th.dt-head-right div.dt-column-header,
+table.dataTable tfoot th.dt-head-right div.dt-column-footer,
+table.dataTable tfoot td.dt-head-right div.dt-column-header,
+table.dataTable tfoot td.dt-head-right div.dt-column-footer {
+ flex-direction: row-reverse;
+}
+table.dataTable thead th.dt-head-justify,
+table.dataTable thead td.dt-head-justify,
+table.dataTable tfoot th.dt-head-justify,
+table.dataTable tfoot td.dt-head-justify {
+ text-align: justify;
+}
+table.dataTable thead th.dt-head-nowrap,
+table.dataTable thead td.dt-head-nowrap,
+table.dataTable tfoot th.dt-head-nowrap,
+table.dataTable tfoot td.dt-head-nowrap {
+ white-space: nowrap;
+}
+table.dataTable tbody th.dt-body-left,
+table.dataTable tbody td.dt-body-left {
+ text-align: left;
+}
+table.dataTable tbody th.dt-body-center,
+table.dataTable tbody td.dt-body-center {
+ text-align: center;
+}
+table.dataTable tbody th.dt-body-right,
+table.dataTable tbody td.dt-body-right {
+ text-align: right;
+}
+table.dataTable tbody th.dt-body-justify,
+table.dataTable tbody td.dt-body-justify {
+ text-align: justify;
+}
+table.dataTable tbody th.dt-body-nowrap,
+table.dataTable tbody td.dt-body-nowrap {
+ white-space: nowrap;
+}
+
+/*! Bootstrap 5 integration for DataTables
+ *
+ * ©2020 SpryMedia Ltd, all rights reserved.
+ * License: MIT datatables.net/license/mit
+ */
+table.table.dataTable {
+ clear: both;
+ margin-bottom: 0;
+ max-width: none;
+ border-spacing: 0;
+}
+table.table.dataTable.table-striped > tbody > tr:nth-of-type(2n+1) > * {
+ box-shadow: none;
+}
+table.table.dataTable > :not(caption) > * > * {
+ background-color: var(--bs-table-bg);
+}
+table.table.dataTable > tbody > tr {
+ background-color: transparent;
+}
+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));
+ color: rgb(255, 255, 255);
+ color: 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));
+}
+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);
+}
+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);
+}
+table.table.dataTable.table-hover > tbody > tr:hover > * {
+ box-shadow: inset 0 0 0 9999px 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);
+}
+
+div.dt-container div.dt-layout-start > *:not(:last-child) {
+ margin-right: 1em;
+}
+div.dt-container div.dt-layout-end > *:not(:first-child) {
+ margin-left: 1em;
+}
+div.dt-container div.dt-layout-full {
+ width: 100%;
+}
+div.dt-container div.dt-layout-full > *:only-child {
+ margin-left: auto;
+ margin-right: auto;
+}
+div.dt-container div.dt-layout-table > div {
+ display: block !important;
+}
+
+@media screen and (max-width: 767px) {
+ div.dt-container div.dt-layout-start > *:not(:last-child) {
+ margin-right: 0;
+ }
+ div.dt-container div.dt-layout-end > *:not(:first-child) {
+ margin-left: 0;
+ }
+}
+div.dt-container {
+ position: relative;
+}
+div.dt-container div.dt-length label {
+ font-weight: normal;
+ text-align: left;
+ white-space: nowrap;
+}
+div.dt-container div.dt-length select {
+ width: auto;
+ display: inline-block;
+ margin-right: 0.5em;
+}
+div.dt-container div.dt-search {
+ text-align: right;
+}
+div.dt-container div.dt-search label {
+ font-weight: normal;
+ white-space: nowrap;
+ text-align: left;
+}
+div.dt-container div.dt-search input {
+ margin-left: 0.5em;
+ display: inline-block;
+ width: auto;
+}
+div.dt-container div.dt-paging {
+ margin: 0;
+}
+div.dt-container div.dt-paging ul.pagination {
+ margin: 2px 0;
+ flex-wrap: wrap;
+}
+div.dt-container div.dt-row {
+ position: relative;
+}
+
+div.dt-scroll-head table.dataTable {
+ margin-bottom: 0 !important;
+}
+
+div.dt-scroll-body {
+ border-bottom-color: var(--bs-border-color);
+ border-bottom-width: var(--bs-border-width);
+ border-bottom-style: solid;
+}
+div.dt-scroll-body > table {
+ border-top: none;
+ margin-top: 0 !important;
+ margin-bottom: 0 !important;
+}
+div.dt-scroll-body > table > tbody > tr:first-child {
+ border-top-width: 0;
+}
+div.dt-scroll-body > table > thead > tr {
+ border-width: 0 !important;
+}
+div.dt-scroll-body > table > tbody > tr:last-child > * {
+ border-bottom: none;
+}
+
+div.dt-scroll-foot > .dt-scroll-footInner {
+ box-sizing: content-box;
+}
+div.dt-scroll-foot > .dt-scroll-footInner > table {
+ margin-top: 0 !important;
+ border-top: none;
+}
+div.dt-scroll-foot > .dt-scroll-footInner > table > tfoot > tr:first-child {
+ border-top-width: 0 !important;
+}
+
+@media screen and (max-width: 767px) {
+ div.dt-container div.dt-length,
+ div.dt-container div.dt-search,
+ div.dt-container div.dt-info,
+ div.dt-container div.dt-paging {
+ text-align: center;
+ }
+ div.dt-container .row {
+ --bs-gutter-y: 0.5rem;
+ }
+ div.dt-container div.dt-paging ul.pagination {
+ justify-content: center !important;
+ }
+}
+table.dataTable.table-sm > thead > tr th.dt-orderable-asc, table.dataTable.table-sm > thead > tr th.dt-orderable-desc, table.dataTable.table-sm > thead > tr th.dt-ordering-asc, table.dataTable.table-sm > thead > tr th.dt-ordering-desc,
+table.dataTable.table-sm > thead > tr td.dt-orderable-asc,
+table.dataTable.table-sm > thead > tr td.dt-orderable-desc,
+table.dataTable.table-sm > thead > tr td.dt-ordering-asc,
+table.dataTable.table-sm > thead > tr td.dt-ordering-desc {
+ padding-right: 20px;
+}
+table.dataTable.table-sm > thead > tr th.dt-orderable-asc span.dt-column-order, table.dataTable.table-sm > thead > tr th.dt-orderable-desc span.dt-column-order, table.dataTable.table-sm > thead > tr th.dt-ordering-asc span.dt-column-order, table.dataTable.table-sm > thead > tr th.dt-ordering-desc span.dt-column-order,
+table.dataTable.table-sm > thead > tr td.dt-orderable-asc span.dt-column-order,
+table.dataTable.table-sm > thead > tr td.dt-orderable-desc span.dt-column-order,
+table.dataTable.table-sm > thead > tr td.dt-ordering-asc span.dt-column-order,
+table.dataTable.table-sm > thead > tr td.dt-ordering-desc span.dt-column-order {
+ right: 5px;
+}
+
+div.dt-scroll-head table.table-bordered {
+ border-bottom-width: 0;
+}
+
+div.table-responsive > div.dt-container > div.row {
+ margin: 0;
+}
+div.table-responsive > div.dt-container > div.row > div[class^=col-]:first-child {
+ padding-left: 0;
+}
+div.table-responsive > div.dt-container > div.row > div[class^=col-]:last-child {
+ padding-right: 0;
+}
+
+:root[data-bs-theme=dark] {
+ --dt-row-hover: 255, 255, 255;
+ --dt-row-stripe: 255, 255, 255;
+ --dt-column-ordering: 255, 255, 255;
+}
diff --git a/public/datatables/responsive.bootstrap5.css b/public/datatables/responsive.bootstrap5.css
new file mode 100644
index 00000000..ce6fcb54
--- /dev/null
+++ b/public/datatables/responsive.bootstrap5.css
@@ -0,0 +1,203 @@
+table.dataTable.dtr-inline.collapsed > tbody > tr > td.child,
+table.dataTable.dtr-inline.collapsed > tbody > tr > th.child,
+table.dataTable.dtr-inline.collapsed > tbody > tr > td.dataTables_empty {
+ cursor: default !important;
+}
+table.dataTable.dtr-inline.collapsed > tbody > tr > td.child:before,
+table.dataTable.dtr-inline.collapsed > tbody > tr > th.child:before,
+table.dataTable.dtr-inline.collapsed > tbody > tr > td.dataTables_empty:before {
+ display: none !important;
+}
+table.dataTable.dtr-inline.collapsed > tbody > tr > td.dtr-control,
+table.dataTable.dtr-inline.collapsed > tbody > tr > th.dtr-control {
+ cursor: pointer;
+}
+table.dataTable.dtr-inline.collapsed > tbody > tr > td.dtr-control:before,
+table.dataTable.dtr-inline.collapsed > tbody > tr > th.dtr-control:before {
+ margin-right: 0.5em;
+ display: inline-block;
+ box-sizing: border-box;
+ content: "";
+ border-top: 5px solid transparent;
+ border-left: 10px solid rgba(0, 0, 0, 0.5);
+ border-bottom: 5px solid transparent;
+ border-right: 0px solid transparent;
+}
+table.dataTable.dtr-inline.collapsed > tbody > tr > td.dtr-control.arrow-right::before,
+table.dataTable.dtr-inline.collapsed > tbody > tr > th.dtr-control.arrow-right::before {
+ border-top: 5px solid transparent;
+ border-left: 0px solid transparent;
+ border-bottom: 5px solid transparent;
+ border-right: 10px solid rgba(0, 0, 0, 0.5);
+}
+table.dataTable.dtr-inline.collapsed > tbody > tr.dtr-expanded > td.dtr-control:before,
+table.dataTable.dtr-inline.collapsed > tbody > tr.dtr-expanded > th.dtr-control:before {
+ border-top: 10px solid rgba(0, 0, 0, 0.5);
+ border-left: 5px solid transparent;
+ border-bottom: 0px solid transparent;
+ border-right: 5px solid transparent;
+}
+table.dataTable.dtr-inline.collapsed.compact > tbody > tr > td.dtr-control,
+table.dataTable.dtr-inline.collapsed.compact > tbody > tr > th.dtr-control {
+ padding-left: 0.333em;
+}
+table.dataTable.dtr-column > tbody > tr > td.dtr-control,
+table.dataTable.dtr-column > tbody > tr > th.dtr-control,
+table.dataTable.dtr-column > tbody > tr > td.control,
+table.dataTable.dtr-column > tbody > tr > th.control {
+ cursor: pointer;
+}
+table.dataTable.dtr-column > tbody > tr > td.dtr-control:before,
+table.dataTable.dtr-column > tbody > tr > th.dtr-control:before,
+table.dataTable.dtr-column > tbody > tr > td.control:before,
+table.dataTable.dtr-column > tbody > tr > th.control:before {
+ display: inline-block;
+ box-sizing: border-box;
+ content: "";
+ border-top: 5px solid transparent;
+ border-left: 10px solid rgba(0, 0, 0, 0.5);
+ border-bottom: 5px solid transparent;
+ border-right: 0px solid transparent;
+}
+table.dataTable.dtr-column > tbody > tr > td.dtr-control.arrow-right::before,
+table.dataTable.dtr-column > tbody > tr > th.dtr-control.arrow-right::before,
+table.dataTable.dtr-column > tbody > tr > td.control.arrow-right::before,
+table.dataTable.dtr-column > tbody > tr > th.control.arrow-right::before {
+ border-top: 5px solid transparent;
+ border-left: 0px solid transparent;
+ border-bottom: 5px solid transparent;
+ border-right: 10px solid rgba(0, 0, 0, 0.5);
+}
+table.dataTable.dtr-column > tbody > tr.dtr-expanded td.dtr-control:before,
+table.dataTable.dtr-column > tbody > tr.dtr-expanded th.dtr-control:before,
+table.dataTable.dtr-column > tbody > tr.dtr-expanded td.control:before,
+table.dataTable.dtr-column > tbody > tr.dtr-expanded th.control:before {
+ border-top: 10px solid rgba(0, 0, 0, 0.5);
+ border-left: 5px solid transparent;
+ border-bottom: 0px solid transparent;
+ border-right: 5px solid transparent;
+}
+table.dataTable > tbody > tr.child {
+ padding: 0.5em 1em;
+}
+table.dataTable > tbody > tr.child:hover {
+ background: transparent !important;
+}
+table.dataTable > tbody > tr.child ul.dtr-details {
+ display: inline-block;
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+}
+table.dataTable > tbody > tr.child ul.dtr-details > li {
+ border-bottom: 1px solid #efefef;
+ padding: 0.5em 0;
+}
+table.dataTable > tbody > tr.child ul.dtr-details > li:first-child {
+ padding-top: 0;
+}
+table.dataTable > tbody > tr.child ul.dtr-details > li:last-child {
+ padding-bottom: 0;
+ border-bottom: none;
+}
+table.dataTable > tbody > tr.child span.dtr-title {
+ display: inline-block;
+ min-width: 75px;
+ font-weight: bold;
+}
+
+div.dtr-modal {
+ position: fixed;
+ box-sizing: border-box;
+ top: 0;
+ left: 0;
+ height: 100%;
+ width: 100%;
+ z-index: 100;
+ padding: 10em 1em;
+}
+div.dtr-modal div.dtr-modal-display {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+ width: 50%;
+ height: fit-content;
+ max-height: 75%;
+ overflow: auto;
+ margin: auto;
+ z-index: 102;
+ overflow: auto;
+ background-color: #f5f5f7;
+ border: 1px solid black;
+ border-radius: 0.5em;
+ box-shadow: 0 12px 30px rgba(0, 0, 0, 0.6);
+}
+div.dtr-modal div.dtr-modal-content {
+ position: relative;
+ padding: 2.5em;
+}
+div.dtr-modal div.dtr-modal-content h2 {
+ margin-top: 0;
+}
+div.dtr-modal div.dtr-modal-close {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ width: 22px;
+ height: 22px;
+ text-align: center;
+ border-radius: 3px;
+ cursor: pointer;
+ z-index: 12;
+}
+div.dtr-modal div.dtr-modal-background {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 101;
+ background: rgba(0, 0, 0, 0.6);
+}
+
+@media screen and (max-width: 767px) {
+ div.dtr-modal div.dtr-modal-display {
+ width: 95%;
+ }
+}
+html.dark table.dataTable > tbody > tr > td.dtr-control:before,
+html[data-bs-theme=dark] table.dataTable > tbody > tr > td.dtr-control:before {
+ border-left-color: rgba(255, 255, 255, 0.5) !important;
+}
+html.dark table.dataTable > tbody > tr > td.dtr-control.arrow-right::before,
+html[data-bs-theme=dark] table.dataTable > tbody > tr > td.dtr-control.arrow-right::before {
+ border-right-color: rgba(255, 255, 255, 0.5) !important;
+}
+html.dark table.dataTable > tbody > tr.dtr-expanded > td.dtr-control:before,
+html.dark table.dataTable > tbody > tr.dtr-expanded > th.dtr-control:before,
+html[data-bs-theme=dark] table.dataTable > tbody > tr.dtr-expanded > td.dtr-control:before,
+html[data-bs-theme=dark] table.dataTable > tbody > tr.dtr-expanded > th.dtr-control:before {
+ border-top-color: rgba(255, 255, 255, 0.5) !important;
+ border-left-color: transparent !important;
+ border-right-color: transparent !important;
+}
+html.dark table.dataTable > tbody > tr.child ul.dtr-details > li,
+html[data-bs-theme=dark] table.dataTable > tbody > tr.child ul.dtr-details > li {
+ border-bottom-color: rgb(64, 67, 70);
+}
+html.dark div.dtr-modal div.dtr-modal-display,
+html[data-bs-theme=dark] div.dtr-modal div.dtr-modal-display {
+ background-color: rgb(33, 37, 41);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+}
+
+div.dtr-bs-modal table.table tr:first-child td {
+ border-top: none;
+}
+
+table.dataTable.table-bordered th.dtr-control.dtr-hidden + *,
+table.dataTable.table-bordered td.dtr-control.dtr-hidden + * {
+ border-left-width: 1px;
+}
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..55eeb38f 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
@@ -118,6 +119,75 @@
end
end
+ describe 'pending notification params' do
+ before do
+ session[:user_id] = instructor.canvas_uid
+ UserToCourse.create!(user: instructor, course: course, role: 'instructor')
+ allow_any_instance_of(Course).to receive(:user_role).with(instructor).and_return('instructor')
+ CourseSettings.create!(course: course, enable_extensions: true)
+ end
+
+ it 'persists pending notification settings' do
+ post :update, params: {
+ course_id: course.id,
+ course_settings: {
+ pending_notification_frequency: 'daily',
+ pending_notification_email: 'prof@berkeley.edu'
+ },
+ tab: 'general'
+ }
+
+ settings = CourseSettings.find_by(course_id: course.id)
+ expect(settings.pending_notification_frequency).to eq('daily')
+ expect(settings.pending_notification_email).to eq('prof@berkeley.edu')
+ end
+
+ it 'normalizes blank frequency to nil' do
+ post :update, params: {
+ course_id: course.id,
+ course_settings: {
+ pending_notification_frequency: '',
+ pending_notification_email: ''
+ },
+ tab: 'general'
+ }
+
+ settings = CourseSettings.find_by(course_id: course.id)
+ expect(settings.pending_notification_frequency).to be_nil
+ end
+
+ it 'clears stored email when frequency is set to blank' do
+ settings = CourseSettings.find_by(course_id: course.id)
+ settings.update!(pending_notification_frequency: 'daily', pending_notification_email: 'prof@berkeley.edu')
+
+ post :update, params: {
+ course_id: course.id,
+ course_settings: {
+ pending_notification_frequency: '',
+ pending_notification_email: ''
+ },
+ tab: 'general'
+ }
+
+ settings.reload
+ expect(settings.pending_notification_frequency).to be_nil
+ expect(settings.pending_notification_email).to be_nil
+ end
+
+ it 'shows validation errors for invalid email with frequency set' do
+ post :update, params: {
+ course_id: course.id,
+ course_settings: {
+ pending_notification_frequency: 'daily',
+ pending_notification_email: 'not-an-email'
+ },
+ tab: 'general'
+ }
+
+ expect(flash[:alert]).to include('Failed to update course settings:')
+ end
+ end
+
describe 'pending requests count' do
let(:assignment) do
# Create necessary related objects for Request
@@ -190,7 +260,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 383e1b05..5cc4e7d3 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
@@ -29,6 +30,14 @@
expect(response).to render_template(:index)
end
+ it 'includes Lead TA enrollments in staff courses' do
+ UserToCourse.create!(user: user, course: student_course, role: 'leadta')
+
+ get :index
+
+ expect(assigns(:teacher_courses).map(&:role)).to include('leadta')
+ end
+
context 'semester grouping' do
let(:spring_course) { Course.create!(course_name: 'Spring Course', canvas_id: 'sp1', course_code: 'SP101', semester: 'Spring 2026') }
let(:fall_course) { Course.create!(course_name: 'Fall Course', canvas_id: 'fa1', course_code: 'FA101', semester: 'Fall 2025') }
@@ -112,6 +121,21 @@
expect(response).to redirect_to(courses_path)
expect(flash[:notice]).to eq('Selected courses and their assignments have been imported successfully.')
end
+
+ it 'imports courses where the user is enrolled with the Canvas Lead TA role' do
+ lead_ta_course = {
+ 'id' => '999',
+ 'name' => 'Lead TA Canvas Course',
+ 'course_code' => 'LTA101',
+ 'enrollments' => [ { 'type' => 'ta', 'role' => 'Lead TA' } ]
+ }
+ allow(Course).to receive(:fetch_courses).and_return([ lead_ta_course ])
+ allow(Course).to receive(:create_or_update_from_canvas)
+
+ post :create, params: { courses: [ '999' ] }
+
+ expect(Course).to have_received(:create_or_update_from_canvas).with(lead_ta_course, 'fake_token', user)
+ end
end
describe 'POST #sync_assignments' do
@@ -139,6 +163,14 @@
headers: { 'Authorization' => 'Bearer fake_token' }
).to_return(status: 200, body: '[]', headers: {})
end
+ stub_request(:get, "#{ENV.fetch('CANVAS_URL', nil)}/api/v1/courses/456/users")
+ .with(
+ query: {
+ 'enrollment_role' => 'Lead TA',
+ 'per_page' => '100'
+ },
+ headers: { 'Authorization' => 'Bearer fake_token' }
+ ).to_return(status: 200, body: '[]', headers: {})
end
context 'when user is a teacher (course admin)' do
@@ -215,7 +247,7 @@
'id' => '103',
'name' => 'Test Course 103',
'course_code' => 'TC103',
- 'enrollments' => [ { 'type' => 'teacher' } ],
+ 'enrollments' => [ { 'type' => 'ta', 'role' => 'Lead TA' } ],
'term' => { 'name' => 'Fall 2025' }
},
{
@@ -230,7 +262,8 @@
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(canvas_courses)
end
@@ -246,8 +279,9 @@
expect(assigns(:courses_student)).not_to be_empty
# Teacher course should be categorized correctly
- teacher_course = assigns(:courses_teacher).first
- expect(teacher_course['enrollments'].first['type']).to eq('teacher')
+ teacher_course_roles = assigns(:courses_teacher).map { |canvas_course| canvas_course['enrollments'].first }
+ expect(teacher_course_roles).to include(hash_including('type' => 'teacher'))
+ expect(teacher_course_roles).to include(hash_including('role' => 'Lead TA'))
# Student course should be categorized correctly
student_course = assigns(:courses_student).first
@@ -304,7 +338,11 @@
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')
CourseToLms.create!(course: course, lms_id: 1)
end
@@ -330,10 +368,16 @@
get :enrollments, params: { id: course.id }
expect(assigns(:is_course_admin)).to be true
end
+
+ it 'assigns @approved_late_days' do
+ get :enrollments, params: { id: course.id }
+ expect(assigns(:approved_late_days)).to be_a(Hash)
+ end
end
context 'when user is a TA (staff but not course admin)' do
before do
+ UserToCourse.where(user: user, course: course).destroy_all
UserToCourse.create!(user: user, course: course, role: 'ta')
end
@@ -352,6 +396,10 @@
end
context 'when user is a student' do
+ before do
+ UserToCourse.where(user: user, course: course, role: 'teacher').destroy_all
+ end
+
it 'redirects with access denied' do
get :enrollments, params: { id: course.id }
expect(response).to redirect_to(courses_path)
diff --git a/spec/controllers/requests_controller_spec.rb b/spec/controllers/requests_controller_spec.rb
index 723cac10..8769219f 100644
--- a/spec/controllers/requests_controller_spec.rb
+++ b/spec/controllers/requests_controller_spec.rb
@@ -1,8 +1,8 @@
require 'rails_helper'
RSpec.describe RequestsController, type: :controller do
- let(:user) { 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(:user) { User.create!(email: 'student@example.com', canvas_uid: 'student-uid-123', name: 'Student') }
+ let(:instructor) { User.create!(email: 'instructor@example.com', canvas_uid: 'instructor-uid-566', name: 'Instructor') }
let(:course) { create(:course, :with_staff, course_name: 'Test Course', canvas_id: '456', course_code: 'TST101') }
let(:teacher_course) { Course.create!(course_name: 'Instructor Course', canvas_id: '999', course_code: 'INST101') }
let(:assignment) do
@@ -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
@@ -78,6 +78,26 @@
get :show, params: { course_id: course.id, id: request.id }
expect(response).to render_template('requests/student_show')
end
+
+ it 'assigns @student_enrollment for instructor view' do
+ session[:user_id] = instructor.canvas_uid
+ UserToCourse.create!(user: instructor, course: course, role: 'teacher')
+ instructor.lms_credentials.create!(
+ lms_id: 1,
+ token: 'fake_token',
+ refresh_token: 'fake_refresh_token',
+ expire_time: 1.hour.from_now
+ )
+
+ get :show, params: { course_id: course.id, id: request.id }
+ expect(assigns(:student_enrollment)).to be_present
+ expect(assigns(:student_enrollment).user).to eq(user)
+ end
+
+ it 'does not assign @student_enrollment for student view' do
+ get :show, params: { course_id: course.id, id: request.id }
+ expect(assigns(:student_enrollment)).to be_nil
+ end
end
describe 'GET #new' do
@@ -314,7 +334,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
@@ -438,10 +458,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
@@ -672,7 +693,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/controllers/session_controller_spec.rb b/spec/controllers/session_controller_spec.rb
index 65b85f6d..6acae4d2 100644
--- a/spec/controllers/session_controller_spec.rb
+++ b/spec/controllers/session_controller_spec.rb
@@ -111,6 +111,61 @@
end
end
+ describe 'GET #omniauth_callback (developer provider)' do
+ let(:dev_auth_hash) do
+ OmniAuth::AuthHash.new(
+ provider: 'developer',
+ uid: 'test@example.com',
+ info: OpenStruct.new(name: 'Test Developer', email: 'test@example.com'),
+ credentials: {
+ token: 'dev-token',
+ refresh_token: nil,
+ expires_at: nil
+ }
+ )
+ end
+
+ before do
+ request.env['omniauth.auth'] = dev_auth_hash
+ end
+
+ context 'developer provider login with missing credentials' do
+ it 'handles nil credentials gracefully' do
+ get :omniauth_callback, params: { provider: 'developer' }
+
+ user = User.find_by(canvas_uid: 'test@example.com')
+ expect(user).to be_present
+ expect(user.email).to eq('test@example.com')
+
+ expect(session[:user_id]).to eq('test@example.com')
+ expect(response).to redirect_to(courses_path)
+ end
+
+ # test course for dev login
+ it 'auto-enrolls developer login users in test course' do
+ test_course = Course.create!(course_code: 'DEV101', course_name: 'Test Course', canvas_id: 'dev-001')
+
+ 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)
+
+ expect(enrollment).to be_present
+ expect(enrollment.role).to eq('student')
+ end
+
+ it 'stores fake refresh token for developer provider' 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
+ end
+ end
+ end
+
describe 'GET #logout' do
before do
session[:user_id] = 'test_user_id'
diff --git a/spec/controllers/user_to_courses_controller_spec.rb b/spec/controllers/user_to_courses_controller_spec.rb
index eaaa61e7..844c3d0f 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
@@ -144,4 +148,88 @@
end
end
end
+
+ describe 'PATCH #update_notes' do
+ context 'when user is an instructor' do
+ before do
+ UserToCourse.create!(user: instructor, course: course, role: 'teacher')
+ student_enrollment
+ session[:user_id] = instructor.canvas_uid
+ instructor.lms_credentials.create!(
+ lms_id: 1,
+ token: 'fake_token',
+ refresh_token: 'fake_refresh_token',
+ expire_time: 1.hour.from_now
+ )
+ end
+
+ it 'successfully saves notes' do
+ patch :update_notes, params: {
+ course_id: course.id,
+ id: student_enrollment.id,
+ notes: 'Student has DSP accommodations for extra time.'
+ }
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body['success']).to be true
+ expect(student_enrollment.reload.notes).to eq('Student has DSP accommodations for extra time.')
+ end
+
+ it 'successfully clears notes' do
+ student_enrollment.update!(notes: 'Old notes')
+
+ patch :update_notes, params: {
+ course_id: course.id,
+ id: student_enrollment.id,
+ notes: ''
+ }
+
+ expect(response).to have_http_status(:ok)
+ expect(student_enrollment.reload.notes).to eq('')
+ end
+
+ it 'returns the saved notes in the response' do
+ patch :update_notes, params: {
+ course_id: course.id,
+ id: student_enrollment.id,
+ notes: 'OKed 3-day extensions for all assignments.'
+ }
+
+ expect(response.parsed_body['notes']).to eq('OKed 3-day extensions for all assignments.')
+ end
+ end
+
+ context 'when user is a student' do
+ before do
+ student_enrollment
+ session[:user_id] = student_user.canvas_uid
+ student_user.lms_credentials.create!(
+ lms_id: 1,
+ token: 'fake_token',
+ refresh_token: 'fake_refresh_token',
+ expire_time: 1.hour.from_now
+ )
+ end
+
+ it 'returns forbidden status' do
+ patch :update_notes, params: {
+ course_id: course.id,
+ id: student_enrollment.id,
+ notes: 'Should not be allowed'
+ }
+
+ expect(response).to have_http_status(:forbidden)
+ end
+
+ it 'does not update the notes' do
+ patch :update_notes, params: {
+ course_id: course.id,
+ id: student_enrollment.id,
+ notes: 'Should not be allowed'
+ }
+
+ expect(student_enrollment.reload.notes).to be_nil
+ end
+ end
+ end
end
diff --git a/spec/facades/canvas_facade_spec.rb b/spec/facades/canvas_facade_spec.rb
index 94a966a3..f935f019 100644
--- a/spec/facades/canvas_facade_spec.rb
+++ b/spec/facades/canvas_facade_spec.rb
@@ -169,6 +169,24 @@
end
end
+ describe '#get_all_course_users' do
+ let(:course) { instance_double(Course, canvas_id: course_id) }
+
+ it 'uses enrollment_type for built-in Canvas roles' do
+ stubs.get("courses/#{course_id}/users?per_page=100&enrollment_type[]=ta") { [ 200, {}, '[]' ] }
+
+ expect(facade.get_all_course_users(course, 'ta')).to eq([])
+ stubs.verify_stubbed_calls
+ end
+
+ it 'uses enrollment_role for the custom Lead TA Canvas role' do
+ stubs.get("courses/#{course_id}/users?per_page=100&enrollment_role=Lead+TA") { [ 200, {}, '[]' ] }
+
+ expect(facade.get_all_course_users(course, 'leadta')).to eq([])
+ stubs.verify_stubbed_calls
+ end
+ end
+
describe 'get_course' do
before do
stubs.get("courses/#{course_id}") { [ 200, {}, '{}' ] }
diff --git a/spec/factories/course_settings.rb b/spec/factories/course_settings.rb
index 2abddc97..682c96f6 100644
--- a/spec/factories/course_settings.rb
+++ b/spec/factories/course_settings.rb
@@ -36,5 +36,15 @@
association :course
enable_extensions { true }
auto_approve_days { 0 }
+
+ trait :with_daily_notifications do
+ pending_notification_frequency { 'daily' }
+ pending_notification_email { 'instructor@example.com' }
+ end
+
+ trait :with_weekly_notifications do
+ pending_notification_frequency { 'weekly' }
+ pending_notification_email { 'instructor@example.com' }
+ end
end
end
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 106827b4..3f614ea2 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/jobs/pending_requests_notification_job_spec.rb b/spec/jobs/pending_requests_notification_job_spec.rb
new file mode 100644
index 00000000..54aa990c
--- /dev/null
+++ b/spec/jobs/pending_requests_notification_job_spec.rb
@@ -0,0 +1,86 @@
+require 'rails_helper'
+
+RSpec.describe PendingRequestsNotificationJob, type: :job do
+ let(:course) { create(:course, canvas_id: 'notif_123', course_name: 'CS 101', course_code: 'CS101') }
+ let(:student) { create(:user, canvas_uid: 'stu_notif_1', email: 'student_notif@example.com', name: 'Student') }
+ let(:lms) { Lms.first }
+ let(:course_to_lms) { CourseToLms.create!(course: course, lms: lms, external_course_id: 'ext_123') }
+ let(:assignment) do
+ Assignment.create!(
+ name: 'HW1',
+ course_to_lms: course_to_lms,
+ due_date: 3.days.from_now,
+ external_assignment_id: 'asgn_notif_1',
+ enabled: true
+ )
+ end
+
+ before do
+ ActionMailer::Base.delivery_method = :test
+ ActionMailer::Base.deliveries.clear
+ allow(ENV).to receive(:fetch).and_call_original
+ allow(ENV).to receive(:fetch).with('DEFAULT_FROM_EMAIL').and_return('flextensions@berkeley.edu')
+ allow(ENV).to receive(:fetch).with('APP_HOST', nil).and_return('http://localhost:3000')
+ end
+
+ describe '#perform' do
+ it 'sends email when course has matching frequency and pending requests' do
+ course.course_settings.update!(pending_notification_frequency: 'daily', pending_notification_email: 'prof@example.com')
+ Request.create!(course: course, assignment: assignment, user: student, status: 'pending',
+ reason: 'Need more time', requested_due_date: 5.days.from_now)
+
+ expect { described_class.perform_now('daily') }.to change { ActionMailer::Base.deliveries.count }.by(1)
+
+ mail = ActionMailer::Base.deliveries.last
+ expect(mail.to).to eq([ 'prof@example.com' ])
+ expect(mail.subject).to include('1 Pending Extension Request')
+ expect(mail.subject).to include('CS101')
+ expect(mail.body.encoded).to include("http://localhost:3000/courses/#{course.id}/requests")
+ end
+
+ it 'skips courses with zero pending requests' do
+ course.course_settings.update!(pending_notification_frequency: 'daily', pending_notification_email: 'prof@example.com')
+
+ expect { described_class.perform_now('daily') }.not_to(change { ActionMailer::Base.deliveries.count })
+ end
+
+ it 'only sends to courses matching the given frequency' do
+ course.course_settings.update!(pending_notification_frequency: 'weekly', pending_notification_email: 'prof@example.com')
+ Request.create!(course: course, assignment: assignment, user: student, status: 'pending',
+ reason: 'Need more time', requested_due_date: 5.days.from_now)
+
+ expect { described_class.perform_now('daily') }.not_to(change { ActionMailer::Base.deliveries.count })
+ end
+
+ it 'pluralizes correctly for multiple pending requests' do
+ course.course_settings.update!(pending_notification_frequency: 'daily', pending_notification_email: 'prof@example.com')
+ 2.times do |i|
+ Request.create!(course: course, assignment: assignment,
+ user: create(:user, canvas_uid: "stu_multi_#{i}", email: "stu_multi_#{i}@example.com"),
+ status: 'pending', reason: 'Need time', requested_due_date: 5.days.from_now)
+ end
+
+ described_class.perform_now('daily')
+
+ mail = ActionMailer::Base.deliveries.last
+ expect(mail.subject).to include('2 Pending Extension Requests')
+ end
+
+ it 'sends separate emails to multiple courses' do
+ course.course_settings.update!(pending_notification_frequency: 'daily', pending_notification_email: 'prof1@example.com')
+ Request.create!(course: course, assignment: assignment, user: student, status: 'pending',
+ reason: 'Need time', requested_due_date: 5.days.from_now)
+
+ other_course = create(:course, canvas_id: 'notif_456', course_name: 'CS 201', course_code: 'CS201')
+ other_ctlms = CourseToLms.create!(course: other_course, lms: lms, external_course_id: 'ext_456')
+ other_assignment = Assignment.create!(name: 'HW2', course_to_lms: other_ctlms, due_date: 3.days.from_now,
+ external_assignment_id: 'asgn_notif_2', enabled: true)
+ other_course.course_settings.update!(pending_notification_frequency: 'daily', pending_notification_email: 'prof2@example.com')
+ other_student = create(:user, canvas_uid: 'stu_notif_2', email: 'stu_notif_2@example.com')
+ Request.create!(course: other_course, assignment: other_assignment, user: other_student, status: 'pending',
+ reason: 'Need time', requested_due_date: 5.days.from_now)
+
+ expect { described_class.perform_now('daily') }.to change { ActionMailer::Base.deliveries.count }.by(2)
+ end
+ end
+end
diff --git a/spec/jobs/sync_all_course_assignments_job_spec.rb b/spec/jobs/sync_all_course_assignments_job_spec.rb
index 079b0dd1..495ecdfd 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,75 @@
end
end
+ describe '#sync_assignment' do
+ let(:job) { described_class.new }
+ let(:results) { { added_assignments: 0, updated_assignments: 0, unchanged_assignments: 0, deleted_assignments: 0 } }
+ let(:lms_assignment) do
+ build_canvas_assignment('id' => 'a123', 'name' => 'HW1', 'due_at' => '2025-06-01T23:59:00Z', 'lock_at' => nil)
+ end
+
+ it 'creates a new assignment and updates results' do
+ 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, course_to_lms, lms_assignment, results)
+ end.to change { Assignment.where(course_to_lms_id: course_to_lms.id).count }.by(1)
- assignment = Assignment.last
+ assignment = Assignment.find_by(course_to_lms_id: 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
+ existing_assignment = create(:assignment,
+ course_to_lms: 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, course_to_lms, lms_assignment, results)
+ end.not_to change { Assignment.where(course_to_lms_id: 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
+
+ it 'increments unchanged_assignments when nothing changed' do
+ create(:assignment,
+ course_to_lms: course_to_lms,
+ external_assignment_id: 'a123',
+ name: 'HW1',
+ due_date: DateTime.parse('2025-06-01T23:59:00Z'),
+ late_due_date: nil
+ )
+
+ lms_assignment = build_canvas_assignment(
+ 'id' => 'a123', 'name' => 'HW1',
+ 'due_at' => '2025-06-01T23:59:00Z', 'lock_at' => nil
+ )
+
+ job.send(:sync_assignment, course_to_lms, lms_assignment, results)
+
+ expect(results[:unchanged_assignments]).to eq(1)
end
end
diff --git a/spec/jobs/sync_users_from_canvas_job_spec.rb b/spec/jobs/sync_users_from_canvas_job_spec.rb
new file mode 100644
index 00000000..bb75694c
--- /dev/null
+++ b/spec/jobs/sync_users_from_canvas_job_spec.rb
@@ -0,0 +1,194 @@
+require 'rails_helper'
+
+RSpec.describe SyncUsersFromCanvasJob, type: :job do
+ let(:course) { create(:course, :with_staff) }
+ let(:sync_user) { course.staff_users.first }
+ let(:canvas_facade_double) { instance_double(CanvasFacade) }
+
+ before do
+ allow(sync_user).to receive(:ensure_fresh_canvas_token!).and_return('fake_token')
+ allow(CanvasFacade).to receive(:new).with('fake_token').and_return(canvas_facade_double)
+ end
+
+ describe '#perform' do
+ context 'user upsert' do
+ let(:canvas_user) { create(:user) }
+ let(:canvas_data) do
+ [ { 'id' => canvas_user.canvas_uid, 'name' => canvas_user.name,
+ 'email' => canvas_user.email, 'sis_user_id' => canvas_user.student_id } ]
+ end
+
+ before do
+ allow(canvas_facade_double).to receive(:get_all_course_users).and_return(canvas_data)
+ end
+
+ it 'creates new users from Canvas data when they do not exist locally' do
+ new_uid = 'brand-new-canvas-uid'
+ allow(canvas_facade_double).to receive(:get_all_course_users)
+ .and_return([ { 'id' => new_uid, 'name' => 'New Person', 'email' => 'new@example.com', 'sis_user_id' => 'S99' } ])
+
+ expect {
+ described_class.perform_now(course.id, sync_user.id, 'student')
+ }.to change(User, :count).by(1)
+
+ expect(User.find_by(canvas_uid: new_uid)).to have_attributes(name: 'New Person', email: 'new@example.com')
+ end
+
+ it 'updates an existing user without creating a duplicate' do
+ canvas_user.update!(name: 'Old Name')
+ updated_data = [ { 'id' => canvas_user.canvas_uid, 'name' => 'New Name',
+ 'email' => canvas_user.email, 'sis_user_id' => canvas_user.student_id } ]
+ allow(canvas_facade_double).to receive(:get_all_course_users).and_return(updated_data)
+
+ expect {
+ described_class.perform_now(course.id, sync_user.id, 'student')
+ }.not_to change(User, :count)
+
+ expect(canvas_user.reload.name).to eq('New Name')
+ end
+
+ it 'skips users with a blank email' do
+ allow(canvas_facade_double).to receive(:get_all_course_users)
+ .and_return([ { 'id' => 'no-email', 'name' => 'No Email', 'email' => '', 'sis_user_id' => nil } ])
+
+ expect {
+ described_class.perform_now(course.id, sync_user.id, 'student')
+ }.not_to change(User, :count)
+ end
+ end
+
+ context 'enrollment creation' do
+ let(:first_student) { create(:user) }
+ let(:second_student) { create(:user) }
+ let(:canvas_data) do
+ [ first_student, second_student ].map do |u|
+ { 'id' => u.canvas_uid, 'name' => u.name, 'email' => u.email, 'sis_user_id' => u.student_id }
+ end
+ end
+
+ before do
+ allow(canvas_facade_double).to receive(:get_all_course_users).and_return(canvas_data)
+ end
+
+ it 'creates UserToCourse enrollments for synced users' do
+ expect {
+ described_class.perform_now(course.id, sync_user.id, 'student')
+ }.to change(UserToCourse, :count).by(2)
+
+ expect(UserToCourse.exists?(user: first_student, course: course, role: 'student')).to be true
+ expect(UserToCourse.exists?(user: second_student, course: course, role: 'student')).to be true
+ end
+
+ it 'assigns the correct role to enrollments' do
+ described_class.perform_now(course.id, sync_user.id, 'ta')
+
+ expect(UserToCourse.find_by(user: first_student, course: course).role).to eq('ta')
+ end
+
+ it 'does not duplicate enrollments on re-run' do
+ described_class.perform_now(course.id, sync_user.id, 'student')
+
+ expect {
+ described_class.perform_now(course.id, sync_user.id, 'student')
+ }.not_to change(UserToCourse, :count)
+ end
+ end
+
+ context 'role-based removal' do
+ let(:remaining) { create(:user) }
+ let(:removed) { create(:user) }
+
+ before do
+ create(:user_to_course, user: removed, course: course, role: 'student')
+ allow(canvas_facade_double).to receive(:get_all_course_users)
+ .and_return([ { 'id' => remaining.canvas_uid, 'name' => remaining.name,
+ 'email' => remaining.email, 'sis_user_id' => remaining.student_id } ])
+ end
+
+ it 'removes enrollments for users no longer returned by Canvas' do
+ # Also pre-enroll `remaining` so the job's insert doesn't offset the removal
+ create(:user_to_course, user: remaining, course: course, role: 'student')
+
+ expect {
+ described_class.perform_now(course.id, sync_user.id, 'student')
+ }.to change(UserToCourse, :count).by(-1)
+
+ expect(UserToCourse.exists?(user: removed, course: course)).to be false
+ end
+
+ it 'does not remove enrollments for other roles' do
+ teacher = create(:user)
+ create(:user_to_course, user: teacher, course: course, role: 'teacher')
+
+ described_class.perform_now(course.id, sync_user.id, 'student')
+
+ expect(UserToCourse.exists?(user: teacher, course: course, role: 'teacher')).to be true
+ end
+ end
+
+ context 'multiple roles' do
+ let(:student) { create(:user) }
+ let(:ta) { create(:user) }
+
+ before do
+ allow(canvas_facade_double).to receive(:get_all_course_users).with(anything, 'student')
+ .and_return([ { 'id' => student.canvas_uid, 'name' => student.name, 'email' => student.email, 'sis_user_id' => student.student_id } ])
+ allow(canvas_facade_double).to receive(:get_all_course_users).with(anything, 'ta')
+ .and_return([ { 'id' => ta.canvas_uid, 'name' => ta.name, 'email' => ta.email, 'sis_user_id' => ta.student_id } ])
+ end
+
+ it 'syncs each role independently' do
+ described_class.perform_now(course.id, sync_user.id, %w[student ta])
+
+ expect(UserToCourse.exists?(user: student, course: course, role: 'student')).to be true
+ expect(UserToCourse.exists?(user: ta, course: course, role: 'ta')).to be true
+ end
+ end
+
+ context 'return value and persistence' do
+ # Use a canvas_uid not in the DB so the job counts this as an add, not an update
+ let(:canvas_data) do
+ [ { 'id' => 'brand-new-uid-999', 'name' => 'New Student', 'email' => 'newstudent@example.com', 'sis_user_id' => 'S999' } ]
+ end
+
+ before do
+ allow(canvas_facade_double).to receive(:get_all_course_users).and_return(canvas_data)
+ end
+
+ it 'returns results keyed by role with added/removed/updated counts' do
+ result = described_class.perform_now(course.id, sync_user.id, 'student')
+
+ # The job keys results with string role names
+ expect(result['student']).to include(added: 1, removed: 0, updated: 0)
+ end
+
+ it 'includes a synced_at timestamp' do
+ result = described_class.perform_now(course.id, sync_user.id, 'student')
+
+ expect(result[:synced_at]).to be_within(1.second).of(Time.current)
+ end
+
+ it 'persists results to course_to_lms.recent_roster_sync' do
+ described_class.perform_now(course.id, sync_user.id, 'student')
+
+ expect(course.course_to_lms(1).reload.recent_roster_sync).to include('student' => include('added' => 1))
+ end
+ end
+
+ context 'when Canvas returns a non-array response' do
+ before do
+ allow(canvas_facade_double).to receive(:get_all_course_users).and_return({ 'error' => 'unauthorized' })
+ end
+
+ it 'returns zeroed counts without raising' do
+ result = nil
+ expect {
+ result = described_class.perform_now(course.id, sync_user.id, 'student')
+ }.not_to raise_error
+
+ # The job keys results with string role names
+ expect(result['student']).to eq(added: 0, removed: 0, updated: 0)
+ end
+ end
+ end
+end
diff --git a/spec/models/course_settings_spec.rb b/spec/models/course_settings_spec.rb
index 31cd24e8..711a894a 100644
--- a/spec/models/course_settings_spec.rb
+++ b/spec/models/course_settings_spec.rb
@@ -177,6 +177,108 @@
end
end
+ describe 'pending notification validations' do
+ context 'pending_notification_frequency' do
+ it 'accepts nil' do
+ course_settings.pending_notification_frequency = nil
+ expect(course_settings).to be_valid
+ end
+
+ it 'accepts "daily"' do
+ course_settings.pending_notification_frequency = 'daily'
+ course_settings.pending_notification_email = 'test@example.com'
+ expect(course_settings).to be_valid
+ end
+
+ it 'accepts "weekly"' do
+ course_settings.pending_notification_frequency = 'weekly'
+ course_settings.pending_notification_email = 'test@example.com'
+ expect(course_settings).to be_valid
+ end
+
+ it 'rejects "monthly"' do
+ course_settings.pending_notification_frequency = 'monthly'
+ course_settings.pending_notification_email = 'test@example.com'
+ expect(course_settings).not_to be_valid
+ expect(course_settings.errors[:pending_notification_frequency]).to be_present
+ end
+ end
+
+ context 'pending_notification_email' do
+ it 'is required when frequency is set' do
+ course_settings.pending_notification_frequency = 'daily'
+ course_settings.pending_notification_email = nil
+ expect(course_settings).not_to be_valid
+ expect(course_settings.errors[:pending_notification_email]).to be_present
+ end
+
+ it 'validates email format when frequency is set' do
+ course_settings.pending_notification_frequency = 'daily'
+ course_settings.pending_notification_email = 'not-an-email'
+ expect(course_settings).not_to be_valid
+ expect(course_settings.errors[:pending_notification_email]).to be_present
+ end
+
+ it 'accepts a valid email when frequency is set' do
+ course_settings.pending_notification_frequency = 'daily'
+ course_settings.pending_notification_email = 'instructor@berkeley.edu'
+ expect(course_settings).to be_valid
+ end
+
+ it 'is not required when frequency is nil' do
+ course_settings.pending_notification_frequency = nil
+ course_settings.pending_notification_email = nil
+ expect(course_settings).to be_valid
+ end
+ end
+
+ context 'normalization' do
+ it 'normalizes empty string frequency to nil' do
+ course_settings.pending_notification_frequency = ''
+ course_settings.valid?
+ expect(course_settings.pending_notification_frequency).to be_nil
+ end
+
+ it 'normalizes empty string email to nil' do
+ course_settings.pending_notification_email = ''
+ course_settings.valid?
+ expect(course_settings.pending_notification_email).to be_nil
+ end
+
+ it 'clears email when frequency is set to nil on save' do
+ course_settings.pending_notification_frequency = 'daily'
+ course_settings.pending_notification_email = 'test@example.com'
+ course_settings.save!
+
+ course_settings.pending_notification_frequency = nil
+ course_settings.save!
+ course_settings.reload
+
+ expect(course_settings.pending_notification_email).to be_nil
+ end
+ end
+ end
+
+ describe '.with_pending_notifications' do
+ it 'returns records matching the given frequency with an email set' do
+ course_settings.update!(pending_notification_frequency: 'daily', pending_notification_email: 'a@example.com')
+
+ 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 = 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) # rubocop:disable Rails/SkipsModelValidations
+
+ results = described_class.with_pending_notifications('daily')
+ expect(results).not_to include(course_settings)
+ end
+ end
+
describe '#extract_gradescope_course_id' do
it 'extracts course ID from valid URL' do
url = 'https://www.gradescope.com/courses/123456'
diff --git a/spec/models/course_spec.rb b/spec/models/course_spec.rb
index d85efb78..6d55bef8 100644
--- a/spec/models/course_spec.rb
+++ b/spec/models/course_spec.rb
@@ -41,8 +41,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
@@ -54,6 +55,16 @@
end
end
+ describe '#user_role' do
+ 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')
+
+ expect(course.user_role(user)).to eq('instructor')
+ end
+ end
+
describe '.fetch_courses' do
it 'returns parsed JSON if response is successful' do
stub_request(:get, %r{api/v1/courses})
@@ -171,12 +182,9 @@
allow(user).to receive(:ensure_fresh_canvas_token!).and_return('fake_token')
end
- it 'creates user and user_to_course 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)
- )
+ it 'enqueues SyncUsersFromCanvasJob with the correct arguments' do
+ expect(SyncUsersFromCanvasJob).to receive(:perform_later).with(course.id, user.id, 'student')
+ course.sync_users_from_canvas(user.id, 'student')
end
end
@@ -235,6 +243,16 @@
end
end
+ describe '#sync_all_enrollments_from_canvas' do
+ let!(:course) { described_class.create!(canvas_id: 'canvas_all_roles', course_name: 'User Sync', course_code: 'USYNC') }
+
+ it 'syncs every supported internal role, including leadta' do
+ expect(SyncUsersFromCanvasJob).to receive(:perform_later).with(course.id, 999, %w[student teacher ta leadta])
+
+ course.sync_all_enrollments_from_canvas(999)
+ end
+ end
+
describe '.create_or_update_from_canvas' do
let(:user) { create(:user, id: 999, canvas_uid: 'u1', name: 'User 1', email: 'user1@example.com') }
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 24bcb0eb..56b16c09 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
@@ -922,4 +923,63 @@
request.send_email_response
end
end
+
+ describe '.total_approved_late_days_by_user' do
+ let(:assignment2) do
+ Assignment.create!(
+ name: 'Assignment 2',
+ course_to_lms_id: course.course_to_lms(1).id,
+ external_assignment_id: 'ext2',
+ enabled: true,
+ due_date: 5.days.from_now
+ )
+ end
+
+ it 'sums approved days across different assignments' do
+ described_class.create!(user: user, course: course, assignment: assignment,
+ reason: 'r', status: 'approved',
+ requested_due_date: assignment.due_date + 2.days)
+ described_class.create!(user: user, course: course, assignment: assignment2,
+ reason: 'r', status: 'approved',
+ requested_due_date: assignment2.due_date + 3.days)
+
+ result = described_class.total_approved_late_days_by_user(course)
+ expect(result[user.id]).to eq(5)
+ end
+
+ it 'takes the wider window when multiple requests exist for the same assignment' do
+ described_class.create!(user: user, course: course, assignment: assignment,
+ reason: 'r', status: 'approved',
+ requested_due_date: assignment.due_date + 2.days)
+ described_class.create!(user: user, course: course, assignment: assignment,
+ reason: 'r', status: 'approved',
+ requested_due_date: assignment.due_date + 5.days)
+
+ result = described_class.total_approved_late_days_by_user(course)
+ expect(result[user.id]).to eq(5)
+ end
+
+ it 'does not count denied requests' do
+ described_class.create!(user: user, course: course, assignment: assignment,
+ reason: 'r', status: 'denied',
+ requested_due_date: assignment.due_date + 3.days)
+
+ result = described_class.total_approved_late_days_by_user(course)
+ expect(result[user.id]).to eq(0)
+ end
+
+ it 'does not count pending requests' do
+ described_class.create!(user: user, course: course, assignment: assignment,
+ reason: 'r', status: 'pending',
+ requested_due_date: assignment.due_date + 3.days)
+
+ result = described_class.total_approved_late_days_by_user(course)
+ expect(result[user.id]).to eq(0)
+ end
+
+ it 'returns 0 for users with no approved requests' do
+ result = described_class.total_approved_late_days_by_user(course)
+ expect(result[user.id]).to eq(0)
+ end
+ end
end
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
diff --git a/spec/models/user_to_course_spec.rb b/spec/models/user_to_course_spec.rb
new file mode 100644
index 00000000..29aef034
--- /dev/null
+++ b/spec/models/user_to_course_spec.rb
@@ -0,0 +1,41 @@
+require 'rails_helper'
+
+RSpec.describe UserToCourse, 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')
+
+ expect(described_class.staff_roles).to include('leadta')
+ expect(described_class.roles).to include('leadta')
+ expect(user_to_course).to be_staff
+ end
+
+ it 'treats leadta as a course admin role' do
+ user_to_course = build(:user_to_course, role: 'leadta')
+
+ expect(user_to_course).to be_course_admin
+ end
+ end
+
+ describe '.role_from_canvas_enrollment' do
+ it 'normalizes Canvas Lead TA custom role enrollments' do
+ enrollment = { 'type' => 'ta', 'role' => 'Lead TA' }
+
+ expect(described_class.role_from_canvas_enrollment(enrollment)).to eq('leadta')
+ end
+
+ it 'falls back to the Canvas enrollment type for built-in roles' do
+ enrollment = { 'type' => 'teacher' }
+
+ expect(described_class.role_from_canvas_enrollment(enrollment)).to eq('teacher')
+ end
+ end
+
+ describe '#display_role' do
+ it 'formats leadta as Lead TA' do
+ user_to_course = build(:user_to_course, role: 'leadta')
+
+ expect(user_to_course.display_role).to eq('Lead TA')
+ end
+ end
+end
diff --git a/spec/tasks/notifications_rake_spec.rb b/spec/tasks/notifications_rake_spec.rb
new file mode 100644
index 00000000..638d1c4e
--- /dev/null
+++ b/spec/tasks/notifications_rake_spec.rb
@@ -0,0 +1,21 @@
+require 'rails_helper'
+require 'rake'
+
+RSpec.describe 'notifications:send_pending_digests' do # rubocop:disable RSpec/DescribeClass
+ before(:all) do
+ Rails.application.load_tasks
+ end
+
+ it 'invokes PendingRequestsNotificationJob with valid frequency' do
+ expect(PendingRequestsNotificationJob).to receive(:perform_now).with('daily')
+ Rake::Task['notifications:send_pending_digests'].reenable
+ Rake::Task['notifications:send_pending_digests'].invoke('daily')
+ end
+
+ it 'aborts with usage message for invalid frequency' do
+ Rake::Task['notifications:send_pending_digests'].reenable
+ expect {
+ Rake::Task['notifications:send_pending_digests'].invoke('monthly')
+ }.to raise_error(SystemExit)
+ end
+end