Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4fa3efd
feat(gradebook): add gradebook page with column picker and CSV export
LWS49 May 20, 2026
b414770
feat(gradebook): add weighted view settings foundation
LWS49 May 20, 2026
1cbe9e8
refactor(table): add columnPicker functionality with column picker di…
LWS49 May 20, 2026
7158142
feat(gradebook): API — expose tab weights + weight update endpoint
LWS49 May 20, 2026
ae6002c
refactor(table): add columnPicker functionality with column picker di…
LWS49 May 20, 2026
68fb6c6
feat(gradebook): add weighted view settings foundation
LWS49 May 20, 2026
8c06e66
feat(gradebook): API — expose tab weights + weight update endpoint
LWS49 May 28, 2026
dba6faa
feat(gradebook): add weighted view UI — table, config dialog, column …
LWS49 May 29, 2026
0f94700
feat(gradebook): add weight_mode and assessment gradebook_weight columns
LWS49 Jun 10, 2026
163e812
feat(gradebook): persist weight mode + per-assessment weights with su…
LWS49 Jun 10, 2026
c68b1b4
feat(gradebook): accept weight mode + assessment weights in update en…
LWS49 Jun 10, 2026
adaa514
feat(gradebook): serialize weightMode and assessment gradebookWeight
LWS49 Jun 10, 2026
3bb54e5
feat(gradebook): add i18n for weight mode toggle and custom validation
LWS49 Jun 10, 2026
c0420e0
feat(gradebook): equal/custom weight modes with per-assessment inputs
LWS49 Jun 10, 2026
bee4f22
feat(gradebook): default weighted-view header to points-out-of labels
LWS49 Jun 10, 2026
3d6d5f4
feat(gradebook): inline per-student assessment breakdown (row expand)
LWS49 Jun 10, 2026
3b1f697
feat(gradebook): points/percentage display toggle in weighted view
LWS49 Jun 10, 2026
48d48fd
refactor(gradebook): add configurable weights calculation options
LWS49 Jun 10, 2026
dea6d9e
feat(gradebook): add ProjectedTotalHint
LWS49 Jun 10, 2026
1d8138e
feat(gradebook): add gradebook_excluded column to assessments
LWS49 Jun 11, 2026
b42923d
feat(gradebook): apply per-assessment exclusion in update_gradebook_w…
LWS49 Jun 11, 2026
251510f
feat(gradebook): accept and echo excludedAssessmentIds in update_weights
LWS49 Jun 11, 2026
16f12f1
feat(gradebook): serialize gradebookExcluded on assessments
LWS49 Jun 11, 2026
617ad86
feat(gradebook): add gradebookExcluded + excludedAssessmentIds types
LWS49 Jun 11, 2026
6eccdc9
feat(gradebook): exclude assessments from weighted compute + breakdow…
LWS49 Jun 11, 2026
ff52805
update GradebookWeightedTable
LWS49 Jun 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions app/controllers/components/course/gradebook_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ def self.display_name
end

def sidebar_items
main_sidebar_items + settings_sidebar_items
end

private

def main_sidebar_items
return [] unless can?(:read_gradebook, current_course)

[
Expand All @@ -20,4 +26,17 @@ def sidebar_items
}
]
end

def settings_sidebar_items
return [] unless can?(:manage_gradebook_settings, current_course)

[
{
key: self.class.key,
type: :settings,
weight: 14,
path: course_admin_gradebook_path(current_course)
}
]
end
end
28 changes: 28 additions & 0 deletions app/controllers/course/admin/gradebook_settings_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true
class Course::Admin::GradebookSettingsController < Course::Admin::Controller
def edit
respond_to(&:json)
end

def update
if @settings.update(gradebook_settings_params) && current_course.save
render 'edit'
else
render json: { errors: @settings.errors }, status: :bad_request
end
end

private

def gradebook_settings_params
params.require(:settings_gradebook_component).permit(:weighted_view_enabled)
end

def component
current_component_host[:course_gradebook_component]
end

def authorize_admin
authorize! :manage_gradebook_settings, current_course
end
end
45 changes: 44 additions & 1 deletion app/controllers/course/gradebook_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class Course::GradebookController < Course::ComponentController
def index
respond_to do |format|
format.json do
@weighted_view_enabled = @settings.weighted_view_enabled
@published_assessments = fetch_published_assessments
@categories, @tabs = fetch_categories_and_tabs
@students = fetch_students
Expand All @@ -18,12 +19,53 @@ def index
end
end

def update_weights
authorize! :manage_gradebook_weights, current_course
updates = update_weights_params[:weights].map { |entry| parse_weight_entry(entry) }
Course::Assessment::Tab.update_gradebook_weights(course: current_course, updates: updates)
render json: { weights: serialize_weight_updates(updates) }
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound => e
render json: { errors: { base: e.message } }, status: :unprocessable_entity
end

private

def authorize_read_gradebook!
authorize! :read_gradebook, current_course
end

def parse_weight_entry(entry)
{
tab_id: entry[:tabId].to_i,
weight: entry[:weight].to_f,
weight_mode: entry[:weightMode] || 'equal',
excluded_assessment_ids: (entry[:excludedAssessmentIds] || []).map(&:to_i),
assessment_weights: (entry[:assessmentWeights] || []).map do |aw|
{ assessment_id: aw[:assessmentId].to_i, weight: aw[:weight].to_f }
end
}
end

def update_weights_params
params.permit(
weights: [:tabId, :weight, :weightMode,
excludedAssessmentIds: [], assessmentWeights: [:assessmentId, :weight]]
)
end

def serialize_weight_updates(updates)
updates.map do |u|
entry = { tabId: u[:tab_id], weight: u[:weight], weightMode: u[:weight_mode].to_s,
excludedAssessmentIds: u[:excluded_assessment_ids] }
if u[:weight_mode].to_s == 'custom'
entry[:assessmentWeights] = u[:assessment_weights].map do |aw|
{ assessmentId: aw[:assessment_id], weight: aw[:weight] }
end
end
entry
end
end

def component
current_component_host[:course_gradebook_component]
end
Expand All @@ -36,7 +78,8 @@ def fetch_categories_and_tabs
def fetch_students
current_course.levels.to_a
current_course.course_users.students.without_phantom_users.
calculated(:experience_points).includes(:user).to_a
calculated(:experience_points).includes(:user).to_a.
sort_by { |cu| cu.user.name }
end

def fetch_published_assessments
Expand Down
2 changes: 2 additions & 0 deletions app/models/components/course/gradebook_ability_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ module Course::GradebookAbilityComponent

def define_permissions
can :read_gradebook, Course, id: course.id if course_user&.staff?
can :manage_gradebook_weights, Course, id: course.id if course_user&.manager_or_owner?
can :manage_gradebook_settings, Course, id: course.id if course_user&.manager_or_owner?
super
end
end
1 change: 1 addition & 0 deletions app/models/course/assessment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class Course::Assessment < ApplicationRecord
validates :creator, presence: true
validates :updater, presence: true
validates :tab, presence: true
validates :gradebook_weight, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
validates :ssid_folder_id, uniqueness: { if: :ssid_folder_id_changed? }, allow_nil: true

belongs_to :tab, inverse_of: :assessments
Expand Down
6 changes: 3 additions & 3 deletions app/models/course/assessment/submission.rb
Original file line number Diff line number Diff line change
Expand Up @@ -331,15 +331,15 @@ def self.grade_summary(student_ids:, assessment_ids:)

find_by_sql(
sanitize_sql_array([<<-SQL.squish, student_ids, assessment_ids])
SELECT cas.creator_id AS student_id, cas.assessment_id,
SUM(caa.grade) AS grade
SELECT cas.id AS submission_id, cas.creator_id AS student_id,
cas.assessment_id, SUM(caa.grade) AS grade
FROM course_assessment_submissions cas
JOIN course_assessment_answers caa ON caa.submission_id = cas.id
WHERE cas.creator_id IN (?)
AND cas.assessment_id IN (?)
AND cas.workflow_state IN ('graded', 'published')
AND caa.current_answer = TRUE
GROUP BY cas.creator_id, cas.assessment_id
GROUP BY cas.id, cas.creator_id, cas.assessment_id
SQL
)
end
Expand Down
72 changes: 72 additions & 0 deletions app/models/course/assessment/tab.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@
class Course::Assessment::Tab < ApplicationRecord
validates :title, length: { maximum: 255 }, presence: true
validates :weight, numericality: { only_integer: true }, presence: true
validates :gradebook_weight,
numericality: { greater_than_or_equal_to: 0,
less_than_or_equal_to: 100 },
presence: true
validates :creator, presence: true
validates :updater, presence: true
validates :category, presence: true

belongs_to :category, class_name: 'Course::Assessment::Category', inverse_of: :tabs
has_many :assessments, class_name: 'Course::Assessment', dependent: :destroy, inverse_of: :tab

enum :weight_mode, { equal: 0, custom: 1 }
has_many :folders, class_name: 'Course::Material::Folder', through: :assessments,
inverse_of: nil

Expand All @@ -24,6 +30,72 @@ class Course::Assessment::Tab < ApplicationRecord
select('(array_agg(title))[0:3]')
end)

# Bulk-updates gradebook weights, weight modes, and per-assessment weights for a set
# of tabs belonging to the given course.
# Raises ActiveRecord::RecordNotFound if any tab_id or assessment_id is unknown.
# Raises ActiveRecord::RecordInvalid if validation fails or, for custom tabs, the
# assessment weights do not sum (at 2dp) to the tab total; the transaction is rolled back.
#
# @param course [Course]
# @param updates [Array<Hash>] each { tab_id:, weight:, weight_mode:,
# excluded_assessment_ids: [Integer], assessment_weights: [{ assessment_id:, weight: }] }
def self.update_gradebook_weights(course:, updates:)
course_tab_ids = course.assessment_tabs.pluck(:id).to_set
updates.each { |e| raise ActiveRecord::RecordNotFound unless course_tab_ids.include?(e[:tab_id]) }

tabs_by_id = where(id: updates.map { |e| e[:tab_id] }).includes(:assessments).index_by(&:id)

transaction { updates.each { |entry| apply_gradebook_weight_entry(tabs_by_id, entry) } }
end

# @api private
def self.apply_gradebook_weight_entry(tabs_by_id, entry)
tab = tabs_by_id[entry[:tab_id]]
mode = (entry[:weight_mode] || 'equal').to_s
tab.update!(gradebook_weight: entry[:weight], weight_mode: mode)

excluded_ids = entry[:excluded_assessment_ids] || []
apply_assessment_exclusions(tab, excluded_ids)

if mode == 'custom'
apply_custom_assessment_weights(tab, entry, excluded_ids.to_set)
else
tab.assessments.update_all(gradebook_weight: nil)
end
end
private_class_method :apply_gradebook_weight_entry

# @api private
# Membership is applied in both modes: excluded ids -> true, the rest of the tab -> false.
def self.apply_assessment_exclusions(tab, excluded_ids)
tab.assessments.where(id: excluded_ids).update_all(gradebook_excluded: true) if excluded_ids.any?
tab.assessments.where.not(id: excluded_ids).update_all(gradebook_excluded: false)
end
private_class_method :apply_assessment_exclusions

# @api private
def self.apply_custom_assessment_weights(tab, entry, excluded_ids) # rubocop:disable Metrics/AbcSize
assessments_by_id = tab.assessments.index_by(&:id)
included_sum = 0
included_any = false
(entry[:assessment_weights] || []).each do |aw|
assessment = assessments_by_id[aw[:assessment_id]]
raise ActiveRecord::RecordNotFound if assessment.nil?

assessment.update!(gradebook_weight: aw[:weight])
next if excluded_ids.include?(aw[:assessment_id])

included_sum += aw[:weight]
included_any = true
end
return unless included_any
return unless (included_sum * 100).round != (entry[:weight] * 100).round

tab.errors.add(:base, :custom_weights_mismatch)
raise ActiveRecord::RecordInvalid, tab
end
private_class_method :apply_custom_assessment_weights

# Returns a boolean value indicating if there are other tabs
# besides this one remaining in its category.
#
Expand Down
16 changes: 16 additions & 0 deletions app/models/course/settings/gradebook_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class Course::Settings::GradebookComponent < Course::Settings::Component
# Returns whether weighted view is enabled (disabled by default).
#
# @return [Boolean] Setting on whether weighted view is enabled.
def weighted_view_enabled
ActiveRecord::Type::Boolean.new.cast(settings.weighted_view_enabled) || false
end

# Enable or disable the weighted view.
#
# @param [Boolean|Integer|String] value Setting on whether weighted view is enabled.
def weighted_view_enabled=(value)
settings.weighted_view_enabled = ActiveRecord::Type::Boolean.new.cast(value)
end
end
2 changes: 2 additions & 0 deletions app/views/course/admin/gradebook_settings/edit.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# frozen_string_literal: true
json.weightedViewEnabled @settings.weighted_view_enabled
10 changes: 10 additions & 0 deletions app/views/course/gradebook/index.json.jbuilder
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
# frozen_string_literal: true
json.weightedViewEnabled @weighted_view_enabled
json.canManageWeights can?(:manage_gradebook_weights, current_course)

json.categories @categories do |cat|
json.id cat.id
json.title cat.title
Expand All @@ -8,13 +11,19 @@ json.tabs @tabs do |tab|
json.id tab.id
json.title tab.title
json.categoryId tab.category_id
if @weighted_view_enabled
json.gradebookWeight tab.gradebook_weight&.to_f
json.weightMode tab.weight_mode
end
end

json.assessments @published_assessments do |assessment|
json.id assessment.id
json.title assessment.title
json.tabId assessment.tab_id
json.maxGrade @assessment_max_grades[assessment.id] || 0
json.gradebookWeight assessment.gradebook_weight&.to_f if @weighted_view_enabled
json.gradebookExcluded assessment.gradebook_excluded if @weighted_view_enabled
end

json.students @students do |course_user|
Expand All @@ -27,6 +36,7 @@ json.students @students do |course_user|
end

json.submissions @submissions do |sub|
json.submissionId sub.submission_id
json.studentId sub.student_id
json.assessmentId sub.assessment_id
json.grade sub.grade&.to_f
Expand Down
2 changes: 2 additions & 0 deletions client/app/__test__/mocks/localeMock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// File used for jest moduleNameMapper - empty locale messages for tests
module.exports = {};
12 changes: 12 additions & 0 deletions client/app/__test__/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,15 @@ jest.mock('react-router-dom', () => ({
useNavigate: jest.fn(),
unstable_usePrompt: jest.fn(),
}));

// Replace I18nProvider with a synchronous stub so tests using test-utils
// don't stall on async locale loading.
jest.mock('lib/components/wrappers/I18nProvider', () => {
const { IntlProvider } = require('react-intl');
const SyncI18nProvider = ({ children }) => (
<IntlProvider defaultLocale="en" locale="en" messages={{}}>
{children}
</IntlProvider>
);
return { __esModule: true, default: SyncI18nProvider };
});
23 changes: 23 additions & 0 deletions client/app/api/course/Admin/Gradebook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { AxiosResponse } from 'axios';
import {
GradebookSettingsData,
GradebookSettingsPostData,
} from 'types/course/admin/gradebook';

import BaseAdminAPI from './Base';

export default class GradebookAdminAPI extends BaseAdminAPI {
override get urlPrefix(): string {
return `${super.urlPrefix}/gradebook`;
}

index(): Promise<AxiosResponse<GradebookSettingsData>> {
return this.client.get(this.urlPrefix);
}

update(
data: GradebookSettingsPostData,
): Promise<AxiosResponse<GradebookSettingsData>> {
return this.client.patch(this.urlPrefix, data);
}
}
2 changes: 2 additions & 0 deletions client/app/api/course/Admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import CommentsAdminAPI from './Comments';
import ComponentsAdminAPI from './Components';
import CourseAdminAPI from './Course';
import ForumsAdminAPI from './Forums';
import GradebookAdminAPI from './Gradebook';
import LeaderboardAdminAPI from './Leaderboard';
import LessonPlanSettingsAPI from './LessonPlan';
import MaterialsAdminAPI from './Materials';
Expand All @@ -28,6 +29,7 @@ const AdminAPI = {
lessonPlan: new LessonPlanSettingsAPI(),
materials: new MaterialsAdminAPI(),
forums: new ForumsAdminAPI(),
gradebook: new GradebookAdminAPI(),
videos: new VideosAdminAPI(),
notifications: new NotificationsSettingsAPI(),
codaveri: new CodaveriAdminAPI(),
Expand Down
8 changes: 7 additions & 1 deletion client/app/api/course/Gradebook.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { GradebookData } from 'types/course/gradebook';
import { GradebookData, UpdateWeightsPayload } from 'types/course/gradebook';

import { APIResponse } from 'api/types';

Expand All @@ -12,4 +12,10 @@ export default class GradebookAPI extends BaseCourseAPI {
index(): APIResponse<GradebookData> {
return this.client.get(this.#urlPrefix);
}

updateWeights(
payload: UpdateWeightsPayload,
): APIResponse<UpdateWeightsPayload> {
return this.client.patch(`${this.#urlPrefix}/weights`, payload);
}
}
Loading