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 = `

${escaped.replace(/\n\n+/g, "

\n\n

").replace(/\n/g, "
")}

`; + this.contentTarget.innerHTML = html; + } else { + this.contentTarget.innerHTML = + 'No notes yet.'; + } + this.formTarget.classList.add("d-none"); + this.displayTarget.classList.remove("d-none"); + this._dispatchFlash("notice", "Notes saved successfully."); + } else { + this._dispatchFlash( + "alert", + data.error || "Failed to save notes." + ); + } + } catch { + this._dispatchFlash("alert", "Network error. Please try again."); + } + } + + _dispatchFlash(type, message) { + window.dispatchEvent( + new CustomEvent("flash", { detail: { type, message } }) + ); + } +} diff --git a/app/javascript/controllers/sync_poller.js b/app/javascript/controllers/sync_poller.js new file mode 100644 index 00000000..0a561169 --- /dev/null +++ b/app/javascript/controllers/sync_poller.js @@ -0,0 +1,11 @@ +export async function pollUntilDone(courseId, key, beforeTs, intervalMs = 1000, timeoutMs = 60000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, intervalMs)); + const r = await fetch(`/courses/${courseId}/sync_status`); + if (!r.ok) throw new Error(`Sync status check failed. ${r.status}`); + const status = await r.json(); + if (status[key] && status[key] !== beforeTs) return; + } + throw new Error("Sync timed out. Please refresh the page."); +} diff --git a/app/jobs/pending_requests_notification_job.rb b/app/jobs/pending_requests_notification_job.rb new file mode 100644 index 00000000..f94faa22 --- /dev/null +++ b/app/jobs/pending_requests_notification_job.rb @@ -0,0 +1,32 @@ +class PendingRequestsNotificationJob < ApplicationJob + queue_as :default + + def perform(frequency) + CourseSettings.with_pending_notifications(frequency).includes(:course).find_each do |cs| + course = cs.course + pending_count = Request.where(course_id: course.id, status: 'pending').count + next if pending_count.zero? + + requests_url = "#{ENV.fetch('APP_HOST', nil)}/courses/#{course.id}/requests" + + EmailService.send_email( + to: cs.pending_notification_email, + from: ENV.fetch('DEFAULT_FROM_EMAIL'), + reply_to: cs.reply_email.presence || ENV.fetch('DEFAULT_FROM_EMAIL'), + subject_template: '{{pending_count}} Pending Extension Request{{plural}} - {{course_code}}', + body_template: "Hello,\n\nYou have {{pending_count}} pending extension request{{plural}} " \ + "in {{course_name}} ({{course_code}}).\n\n" \ + "Please review them at: {{requests_url}}\n\n" \ + "Thank you,\nFlextensions", + mapping: { + 'pending_count' => pending_count.to_s, + 'plural' => pending_count == 1 ? '' : 's', + 'course_name' => course.course_name, + 'course_code' => course.course_code, + 'requests_url' => requests_url + }, + deliver_later: false + ) + end + end +end diff --git a/app/jobs/sync_all_course_assignments_job.rb b/app/jobs/sync_all_course_assignments_job.rb index d39f9aff..cec3ebd1 100644 --- a/app/jobs/sync_all_course_assignments_job.rb +++ b/app/jobs/sync_all_course_assignments_job.rb @@ -47,8 +47,10 @@ def sync_assignment(course_to_lms, lms_assignment, results) # Use shared LmsAssignment to populate Assignment assignment.name = lms_assignment.name - assignment.due_date = lms_assignment.due_date - assignment.late_due_date = lms_assignment.late_due_date + unless preserve_existing_dates?(assignment, lms_assignment) + assignment.due_date = lms_assignment.due_date + assignment.late_due_date = lms_assignment.late_due_date + end assignment.external_assignment_id = lms_assignment.id if assignment.new_record? @@ -60,4 +62,14 @@ def sync_assignment(course_to_lms, lms_assignment, results) end assignment.save! end + + private + + def preserve_existing_dates?(assignment, lms_assignment) + return false if assignment.new_record? + return false unless lms_assignment.is_a?(Lmss::Canvas::Assignment) + return false if lms_assignment.base_date_present? + + assignment.due_date.present? || assignment.late_due_date.present? + end end diff --git a/app/models/course.rb b/app/models/course.rb index 9f4ac9b7..fed5c246 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -87,8 +87,8 @@ def enabled_assignments # Or is user.staff_role?(course) or user.student_role?(course) better? def user_role(user) roles = UserToCourse.where(user_id: user.id, course_id: id).pluck(:role) - return 'instructor' if roles.include?('teacher') || roles.include?('ta') - return 'student' if roles.include?('student') + return 'instructor' if roles.intersect?(UserToCourse.staff_roles) + return 'student' if roles.include?(UserToCourse::STUDENT_ROLE) nil end @@ -118,11 +118,11 @@ def assignments end def students - user_to_courses.where(role: 'student').map(&:user) + user_to_courses.where(role: UserToCourse::STUDENT_ROLE).map(&:user) end def instructors - user_to_courses.where(role: 'teacher').map(&:user) + user_to_courses.where(role: UserToCourse::TEACHER_ROLE).map(&:user) end def staff_users @@ -246,18 +246,18 @@ def sync_assignments(sync_user) return unless lms_links.any? lms_links.each do |course_to_lms| - SyncAllCourseAssignmentsJob.perform_now(course_to_lms.id, sync_user.id) + SyncAllCourseAssignmentsJob.perform_later(course_to_lms.id, sync_user.id) end end # Fetch users for a course and create/find their User and UserToCourse records # TODO: This may need to become a background job def sync_users_from_canvas(user, roles = [ 'student' ]) - SyncUsersFromCanvasJob.perform_now(id, user, roles) + SyncUsersFromCanvasJob.perform_later(id, user, roles) end def sync_all_enrollments_from_canvas(user) - sync_users_from_canvas(user, [ 'teacher', 'ta', 'student' ]) + sync_users_from_canvas(user, UserToCourse.roles) end def regenerate_readonly_api_token_if_blank diff --git a/app/models/course_settings.rb b/app/models/course_settings.rb index 10488a4c..0ef7cf6c 100644 --- a/app/models/course_settings.rb +++ b/app/models/course_settings.rb @@ -49,12 +49,25 @@ class CourseSettings < ApplicationRecord {{course_name}} Staff LIQUID + VALID_NOTIFICATION_FREQUENCIES = %w[daily weekly].freeze + belongs_to :course + before_validation -> { self.pending_notification_frequency = nil if pending_notification_frequency.blank? } + before_validation -> { self.pending_notification_email = nil if pending_notification_email.blank? } before_save :ensure_system_user_for_auto_approval + before_save -> { self.pending_notification_email = nil if pending_notification_frequency.nil? } validate :gradescope_url_is_valid, if: :enable_gradescope? + validates :pending_notification_frequency, inclusion: { in: VALID_NOTIFICATION_FREQUENCIES }, allow_nil: true + validates :pending_notification_email, presence: true, format: { with: /\A[^@\s]+@[^@\s]+\z/ }, + if: -> { pending_notification_frequency.present? } after_save :create_or_update_gradescope_link + scope :with_pending_notifications, ->(frequency) { + where(pending_notification_frequency: frequency) + .where.not(pending_notification_email: [ nil, '' ]) + } + def automatic_approval_enabled? return false unless enable_extensions? diff --git a/app/models/lms.rb b/app/models/lms.rb index 98ba63de..8fa66da3 100644 --- a/app/models/lms.rb +++ b/app/models/lms.rb @@ -17,23 +17,28 @@ class Lms < ApplicationRecord # Relationship with Assignment has_many :assignments + # Relationship with LmsCredential + has_many :lms_credentials, dependent: :destroy + # Singleton instances for each LMS def self.CANVAS_LMS - @canvas_lms ||= find_or_create_by( + @canvas_lms ||= find_by(id: 1) || find_or_create_by!( id: 1, - lms_name: 'Canvas', - lms_base_url: ENV.fetch('CANVAS_URL', ''), - use_auth_token: true - ) + lms_name: 'Canvas' + ) do |lms| + lms.lms_base_url = ENV.fetch('CANVAS_URL', '') + lms.use_auth_token = true + end end def self.GRADESCOPE_LMS - @gradescope_lms ||= find_or_create_by( + @gradescope_lms ||= find_by(id: 2) || find_or_create_by!( id: 2, - lms_name: 'Gradescope', - lms_base_url: 'https://www.gradescope.com', - use_auth_token: false - ) + lms_name: 'Gradescope' + ) do |lms| + lms.lms_base_url = 'https://www.gradescope.com' + lms.use_auth_token = false + end end # Map a linked LMS to the appropriate API facade which can be used to post extension requests diff --git a/app/models/lms_credential.rb b/app/models/lms_credential.rb index aa895068..966b0eb6 100644 --- a/app/models/lms_credential.rb +++ b/app/models/lms_credential.rb @@ -5,7 +5,6 @@ # # id :bigint not null, primary key # expire_time :datetime -# lms_name :string # password :string # refresh_token :string # token :string @@ -13,6 +12,7 @@ # created_at :datetime not null # updated_at :datetime not null # external_user_id :string +# lms_id :bigint # user_id :bigint # # Indexes @@ -21,15 +21,12 @@ # # Foreign Keys # +# fk_rails_... (lms_id => lmss.id) # fk_rails_... (user_id => users.id) # class LmsCredential < ApplicationRecord - # Belongs to a User belongs_to :user + belongs_to :lms - # Encryption for tokens encrypts :token, :refresh_token - - # LMS must exist - validates :lms_name, presence: true end diff --git a/app/models/request.rb b/app/models/request.rb index 2d69d957..eade0f62 100644 --- a/app/models/request.rb +++ b/app/models/request.rb @@ -50,6 +50,19 @@ class Request < ApplicationRecord where(user: user, course: course, status: 'approved', auto_approved: true) } + # Returns { user_id => total_approved_late_days } for all users in a course + # only the widest approved extension is counted. + def self.total_approved_late_days_by_user(course) + rows = where(course: course, status: 'approved') + .joins(:assignment) + .group(:user_id, :assignment_id) + .pluck(:user_id, Arel.sql('GREATEST(0, MAX(requested_due_date::date - assignments.due_date::date))')) + + rows.each_with_object(Hash.new(0)) do |(user_id, max_days), totals| + totals[user_id] += max_days + end + end + # Class methods def self.merge_date_and_time!(request_params) return unless request_params[:requested_due_date].present? && request_params[:due_time].present? diff --git a/app/models/user.rb b/app/models/user.rb index 9cac5f45..6695a9a7 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -38,9 +38,8 @@ class User < ApplicationRecord has_many :user_to_courses has_many :courses, through: :user_to_courses - # TODO: We should probably use lms_id over lms_name def canvas_credentials - lms_credentials.find_by(lms_name: 'canvas') + lms_credentials.find_by(lms_id: Lms.CANVAS_LMS.id) end def token_expired? diff --git a/app/models/user_to_course.rb b/app/models/user_to_course.rb index abb63fa2..9a8af215 100644 --- a/app/models/user_to_course.rb +++ b/app/models/user_to_course.rb @@ -23,6 +23,16 @@ # # TODO: In the future we should name this CourseEnrollment class UserToCourse < ApplicationRecord + STUDENT_ROLE = 'student'.freeze + TEACHER_ROLE = 'teacher'.freeze + TA_ROLE = 'ta'.freeze + LEAD_TA_ROLE = 'leadta'.freeze + STAFF_ROLES = [ TEACHER_ROLE, TA_ROLE, LEAD_TA_ROLE ].freeze + COURSE_ADMIN_ROLES = [ TEACHER_ROLE, LEAD_TA_ROLE ].freeze + ROLE_LABELS = { + LEAD_TA_ROLE => 'Lead TA' + }.freeze + # Associations belongs_to :user belongs_to :course @@ -39,18 +49,52 @@ def staff? end def course_admin? - role == 'teacher' || role == 'leadta' + UserToCourse.course_admin_roles.include?(role) end def student? - role == 'student' + role == STUDENT_ROLE + end + + def display_role + UserToCourse.display_role(role) + end + + def teacher? + role == 'teacher' end def self.roles - [ 'student' ] + UserToCourse.staff_roles + [ STUDENT_ROLE ] + UserToCourse.staff_roles end def self.staff_roles - %w[teacher ta leadta] + STAFF_ROLES + end + + def self.course_admin_roles + COURSE_ADMIN_ROLES + end + + def self.normalize_role(role) + role.to_s.downcase.gsub(/[^a-z]/, '') + end + + def self.role_from_canvas_enrollment(enrollment) + return nil unless enrollment + + normalized_role = normalize_role(enrollment['role'] || enrollment[:role]) + return LEAD_TA_ROLE if normalized_role == LEAD_TA_ROLE + + normalized_type = normalize_role(enrollment['type'] || enrollment[:type]) + roles.include?(normalized_type) ? normalized_type : nil + end + + def self.staff_enrollment?(enrollment) + staff_roles.include?(role_from_canvas_enrollment(enrollment)) + end + + def self.display_role(role) + ROLE_LABELS.fetch(role.to_s, role.to_s.capitalize) end end diff --git a/app/views/courses/edit.html.erb b/app/views/courses/edit.html.erb index 9214779e..f19b3fde 100644 --- a/app/views/courses/edit.html.erb +++ b/app/views/courses/edit.html.erb @@ -211,6 +211,34 @@ +
+ +
+ <%= select_tag 'course_settings[pending_notification_frequency]', + options_for_select( + [['No notifications', ''], ['Daily', 'daily'], ['Once weekly (Fridays)', 'weekly']], + @course.course_settings&.pending_notification_frequency + ), + class: 'form-select', + id: 'pending-notification-frequency', + data: { action: 'change->course-settings#togglePendingNotificationEmail' } %> +
+
+ +
+ +
+ <%= email_field_tag 'course_settings[pending_notification_email]', + @course.course_settings&.pending_notification_email, + class: 'form-control', + id: 'pending-notification-email', + placeholder: 'instructor@berkeley.edu', + data: { course_settings_target: 'pendingNotificationEmail' }, + disabled: @course.course_settings&.pending_notification_frequency.blank? %> +
Weekly notifications are sent on Fridays at 5:00 PM PT.
+
+
+
<% if @course.course_settings&.enable_extensions %> diff --git a/app/views/courses/enrollments.html.erb b/app/views/courses/enrollments.html.erb index 80750df6..1d69bad9 100644 --- a/app/views/courses/enrollments.html.erb +++ b/app/views/courses/enrollments.html.erb @@ -13,6 +13,7 @@ Student ID Email Role + Approved Days <% if @is_course_admin %> Extended Requests? @@ -52,6 +53,7 @@ <%= enrollment.user.student_id %> <%= enrollment.user.email %> <%= enrollment.role.downcase.capitalize %> + <%= enrollment.student? ? @approved_late_days.fetch(enrollment.user_id, 0) : '' %> <% if @is_course_admin %> @@ -82,11 +84,13 @@
<% end %>
- \ No newline at end of file + diff --git a/app/views/courses/index.html.erb b/app/views/courses/index.html.erb index b1323b6e..09a2c713 100644 --- a/app/views/courses/index.html.erb +++ b/app/views/courses/index.html.erb @@ -33,7 +33,7 @@ <%= user_to_course.course.course_name %> <%= user_to_course.course.course_code %> - <%= user_to_course.role.capitalize %> + <%= user_to_course.display_role %> <% end %> <% end %> diff --git a/app/views/courses/instructor_show.html.erb b/app/views/courses/instructor_show.html.erb index 3f0ed8ef..48812a1b 100644 --- a/app/views/courses/instructor_show.html.erb +++ b/app/views/courses/instructor_show.html.erb @@ -49,12 +49,25 @@ -
+
+ +
diff --git a/app/views/courses/new.html.erb b/app/views/courses/new.html.erb index a6585801..dfd1a870 100644 --- a/app/views/courses/new.html.erb +++ b/app/views/courses/new.html.erb @@ -46,7 +46,10 @@ <%= course.dig("term", "name") %> <%= course["course_code"] %> <%= course["name"] %> - <%= course['enrollments'].find { |enrollment| %w[teacher ta].include?(enrollment['type']) }['type'].capitalize %> + + <% staff_enrollment = course['enrollments'].find { |enrollment| UserToCourse.staff_enrollment?(enrollment) } %> + <%= UserToCourse.display_role(UserToCourse.role_from_canvas_enrollment(staff_enrollment)) %> + <% end %> diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb index d500226b..f356c5f5 100644 --- a/app/views/home/index.html.erb +++ b/app/views/home/index.html.erb @@ -23,7 +23,9 @@
<%= link_to 'Login', '/auth/canvas', class: 'btn btn-primary', id: 'login-button-index' %> - <%# Login %> + <% if Rails.env.development? %> + <%= link_to 'Developer Login', '/auth/developer', class: 'btn btn-secondary ms-2', id: 'dev-login-button' %> + <% end %>
diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index f1090860..28666e5d 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -29,7 +29,13 @@