Skip to content

Commit a1a8b69

Browse files
committed
feat(gradebook): add weighted gradebook view
Add weighted view built on top of gradebook: - add tables course_gradebook_contributions and course_gradebook_assessment_contributions - weighted table with equal/custom weight modes and per-assessment weight inputs, with a sum gate on custom weights - points / percentage display toggle - inline per-student assessment breakdown (row expand) - projected-total hint - gradebook_excluded column, serialization, and update-weights API echo - per-assessment include/exclude in the configure-weights modal, seeding custom weights from included assessments only - excluded assessments shown in the breakdown with no contribution - add SegmentedSelect component for stylized selection that is not "on-off"
1 parent eda6ef5 commit a1a8b69

63 files changed

Lines changed: 7917 additions & 38 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/controllers/components/course/gradebook_component.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ def self.display_name
77
end
88

99
def sidebar_items
10+
main_sidebar_items + settings_sidebar_items
11+
end
12+
13+
private
14+
15+
def main_sidebar_items
1016
return [] unless can?(:read_gradebook, current_course)
1117

1218
[
@@ -19,4 +25,17 @@ def sidebar_items
1925
}
2026
]
2127
end
28+
29+
def settings_sidebar_items
30+
return [] unless can?(:manage_gradebook_settings, current_course)
31+
32+
[
33+
{
34+
key: self.class.key,
35+
type: :settings,
36+
weight: 14,
37+
path: course_admin_gradebook_path(current_course)
38+
}
39+
]
40+
end
2241
end
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# frozen_string_literal: true
2+
class Course::Admin::GradebookSettingsController < Course::Admin::Controller
3+
def edit
4+
respond_to(&:json)
5+
end
6+
7+
def update
8+
if @settings.update(gradebook_settings_params) && current_course.save
9+
render 'edit'
10+
else
11+
render json: { errors: @settings.errors }, status: :bad_request
12+
end
13+
end
14+
15+
private
16+
17+
def gradebook_settings_params
18+
params.require(:settings_gradebook_component).permit(:weighted_view_enabled)
19+
end
20+
21+
def component
22+
current_component_host[:course_gradebook_component]
23+
end
24+
25+
def authorize_admin
26+
authorize! :manage_gradebook_settings, current_course
27+
end
28+
end

app/controllers/course/gradebook_controller.rb

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ class Course::GradebookController < Course::ComponentController
66
def index
77
respond_to do |format|
88
format.json do
9+
@weighted_view_enabled = @settings.weighted_view_enabled
910
@published_assessments = fetch_published_assessments
1011
@categories, @tabs = fetch_categories_and_tabs
1112
@students = fetch_students
1213
assessment_ids = @published_assessments.pluck(:id)
14+
load_weighted_view_contributions(assessment_ids) if @weighted_view_enabled
1315
@assessment_max_grades = Course::Assessment.max_grades(assessment_ids)
1416
@submissions = Course::Assessment::Submission.grade_summary(
1517
student_ids: @students.map(&:user_id),
@@ -19,12 +21,60 @@ def index
1921
end
2022
end
2123

24+
def update_weights
25+
authorize! :manage_gradebook_weights, current_course
26+
updates = update_weights_params[:weights].map { |entry| parse_weight_entry(entry) }
27+
Course::Gradebook::Contribution.bulk_update(course: current_course, updates: updates)
28+
render json: { weights: serialize_weight_updates(updates) }
29+
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound => e
30+
render json: { errors: { base: e.message } }, status: :unprocessable_entity
31+
end
32+
2233
private
2334

2435
def authorize_read_gradebook!
2536
authorize! :read_gradebook, current_course
2637
end
2738

39+
def load_weighted_view_contributions(assessment_ids)
40+
@tab_contributions = Course::Gradebook::Contribution.
41+
where(tab_id: @tabs.map(&:id)).index_by(&:tab_id)
42+
@assessment_contributions = Course::Gradebook::AssessmentContribution.
43+
where(assessment_id: assessment_ids).index_by(&:assessment_id)
44+
end
45+
46+
def parse_weight_entry(entry)
47+
{
48+
tab_id: entry[:tabId].to_i,
49+
weight: entry[:weight].to_f,
50+
weight_mode: entry[:weightMode] || 'equal',
51+
excluded_assessment_ids: (entry[:excludedAssessmentIds] || []).map(&:to_i),
52+
assessment_weights: (entry[:assessmentWeights] || []).map do |aw|
53+
{ assessment_id: aw[:assessmentId].to_i, weight: aw[:weight].to_f }
54+
end
55+
}
56+
end
57+
58+
def update_weights_params
59+
params.permit(
60+
weights: [:tabId, :weight, :weightMode,
61+
excludedAssessmentIds: [], assessmentWeights: [:assessmentId, :weight]]
62+
)
63+
end
64+
65+
def serialize_weight_updates(updates)
66+
updates.map do |u|
67+
entry = { tabId: u[:tab_id], weight: u[:weight], weightMode: u[:weight_mode].to_s,
68+
excludedAssessmentIds: u[:excluded_assessment_ids] }
69+
if u[:weight_mode].to_s == 'custom'
70+
entry[:assessmentWeights] = u[:assessment_weights].map do |aw|
71+
{ assessmentId: aw[:assessment_id], weight: aw[:weight] }
72+
end
73+
end
74+
entry
75+
end
76+
end
77+
2878
def component
2979
current_component_host[:course_gradebook_component]
3080
end

app/models/components/course/gradebook_ability_component.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ module Course::GradebookAbilityComponent
33
include AbilityHost::Component
44

55
def define_permissions
6-
can :read_gradebook, Course, id: course.id if course_user&.staff?
6+
can :read_gradebook, Course, id: course.id if course_user&.manager_or_owner?
7+
can :manage_gradebook_weights, Course, id: course.id if course_user&.manager_or_owner?
8+
can :manage_gradebook_settings, Course, id: course.id if course_user&.manager_or_owner?
79
super
810
end
911
end

app/models/course.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ class Course < ApplicationRecord
5353
dependent: :destroy, inverse_of: :course
5454
has_many :assessment_tabs, source: :tabs, through: :assessment_categories
5555
has_many :assessments, through: :assessment_categories
56+
has_many :gradebook_contributions, class_name: 'Course::Gradebook::Contribution',
57+
dependent: :destroy, inverse_of: :course
5658
has_many :assessment_skills, class_name: 'Course::Assessment::Skill',
5759
dependent: :destroy
5860
has_many :assessment_skill_branches, class_name: 'Course::Assessment::SkillBranch',

app/models/course/assessment.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ class Course::Assessment < ApplicationRecord
7979
inverse_of: :assessment, dependent: :destroy
8080
has_one :plagiarism_check, class_name: 'Course::Assessment::PlagiarismCheck',
8181
inverse_of: :assessment, dependent: :destroy, autosave: true
82+
has_one :gradebook_assessment_contribution,
83+
class_name: 'Course::Gradebook::AssessmentContribution',
84+
dependent: :destroy, inverse_of: :assessment
8285
has_many :live_feedbacks, class_name: 'Course::Assessment::LiveFeedback',
8386
inverse_of: :assessment, dependent: :destroy
8487
has_many :links, class_name: 'Course::Assessment::Link', inverse_of: :assessment, dependent: :destroy

app/models/course/assessment/tab.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ class Course::Assessment::Tab < ApplicationRecord
88

99
belongs_to :category, class_name: 'Course::Assessment::Category', inverse_of: :tabs
1010
has_many :assessments, class_name: 'Course::Assessment', dependent: :destroy, inverse_of: :tab
11+
has_one :gradebook_contribution, class_name: 'Course::Gradebook::Contribution',
12+
dependent: :destroy, inverse_of: :tab
13+
1114
has_many :folders, class_name: 'Course::Material::Folder', through: :assessments,
1215
inverse_of: nil
1316

app/models/course/gradebook.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# frozen_string_literal: true
2+
module Course::Gradebook
3+
def self.table_name_prefix
4+
"#{Course.table_name.singularize}_gradebook_"
5+
end
6+
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
class Course::Gradebook::AssessmentContribution < ApplicationRecord
3+
belongs_to :assessment, class_name: 'Course::Assessment',
4+
inverse_of: :gradebook_assessment_contribution
5+
6+
validates :creator, presence: true
7+
validates :updater, presence: true
8+
validates :weight, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
9+
validates :excluded, inclusion: { in: [true, false] }
10+
validates :assessment_id, uniqueness: true
11+
end
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# frozen_string_literal: true
2+
class Course::Gradebook::Contribution < ApplicationRecord
3+
# `prefix: true` keeps Rails from generating a bare `equal?` predicate that would
4+
# override Ruby's Object#equal? (identity, arity 1). Helpers become
5+
# `weight_mode_equal?` etc.; the `weight_mode` reader still returns 'equal'/'custom'.
6+
enum :weight_mode, { equal: 0, custom: 1 }, prefix: true
7+
8+
belongs_to :course, inverse_of: :gradebook_contributions
9+
belongs_to :tab, class_name: 'Course::Assessment::Tab',
10+
inverse_of: :gradebook_contribution, optional: true
11+
12+
validates :creator, presence: true
13+
validates :updater, presence: true
14+
validates :weight, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
15+
validates :weight_mode, presence: true
16+
validates :tab_id, uniqueness: true
17+
validate :exactly_one_contributor
18+
validate :course_matches_contributor
19+
# Bulk-upserts tab contributions and their per-assessment contributions for a course.
20+
# Consumes the identical `updates` payload the controller parses today.
21+
# Raises ActiveRecord::RecordNotFound if any tab_id/assessment_id is unknown, and
22+
# ActiveRecord::RecordInvalid if validation fails or, for custom tabs, the included
23+
# assessment weights do not sum (at 2dp) to the tab total; the transaction rolls back.
24+
#
25+
# @param course [Course]
26+
# @param updates [Array<Hash>] each { tab_id:, weight:, weight_mode:,
27+
# excluded_assessment_ids: [Integer], assessment_weights: [{ assessment_id:, weight: }] }
28+
def self.bulk_update(course:, updates:)
29+
course_tab_ids = course.assessment_tabs.pluck(:id).to_set
30+
updates.each { |e| raise ActiveRecord::RecordNotFound unless course_tab_ids.include?(e[:tab_id]) }
31+
32+
tabs_by_id = Course::Assessment::Tab.where(id: updates.map { |e| e[:tab_id] }).
33+
includes(:assessments).index_by(&:id)
34+
35+
transaction { updates.each { |entry| apply_entry(course, tabs_by_id, entry) } }
36+
end
37+
38+
# @api private
39+
def self.apply_entry(course, tabs_by_id, entry)
40+
tab = tabs_by_id[entry[:tab_id]]
41+
mode = (entry[:weight_mode] || 'equal').to_s
42+
43+
contribution = find_or_initialize_by(tab_id: tab.id)
44+
contribution.course = course
45+
contribution.assign_attributes(weight: entry[:weight], weight_mode: mode)
46+
contribution.save!
47+
48+
excluded_ids = entry[:excluded_assessment_ids] || []
49+
apply_assessment_exclusions(tab, excluded_ids)
50+
51+
if mode == 'custom'
52+
apply_custom_assessment_weights(tab, entry, excluded_ids.to_set)
53+
else
54+
clear_assessment_weights(tab)
55+
end
56+
end
57+
private_class_method :apply_entry
58+
59+
# @api private
60+
def self.assessment_contribution_for(assessment)
61+
Course::Gradebook::AssessmentContribution.find_or_initialize_by(assessment_id: assessment.id)
62+
end
63+
private_class_method :assessment_contribution_for
64+
65+
# @api private
66+
# Membership applies in both modes: excluded ids -> true, the rest of the tab -> false.
67+
def self.apply_assessment_exclusions(tab, excluded_ids)
68+
excluded_set = excluded_ids.to_set
69+
tab.assessments.each do |assessment|
70+
ac = assessment_contribution_for(assessment)
71+
ac.excluded = excluded_set.include?(assessment.id)
72+
ac.save!
73+
end
74+
end
75+
private_class_method :apply_assessment_exclusions
76+
77+
# @api private
78+
def self.clear_assessment_weights(tab)
79+
tab.assessments.each do |assessment|
80+
ac = assessment_contribution_for(assessment)
81+
ac.weight = nil
82+
ac.save!
83+
end
84+
end
85+
private_class_method :clear_assessment_weights
86+
87+
# @api private
88+
def self.apply_custom_assessment_weights(tab, entry, excluded_ids)
89+
assessments_by_id = tab.assessments.index_by(&:id)
90+
included_sum = 0
91+
included_any = false
92+
(entry[:assessment_weights] || []).each do |aw|
93+
assessment = assessments_by_id[aw[:assessment_id]]
94+
raise ActiveRecord::RecordNotFound if assessment.nil?
95+
96+
ac = assessment_contribution_for(assessment)
97+
ac.weight = aw[:weight]
98+
ac.save!
99+
next if excluded_ids.include?(aw[:assessment_id])
100+
101+
included_sum += aw[:weight]
102+
included_any = true
103+
end
104+
validate_custom_assessment_weights_sum!(tab, entry, included_sum, included_any)
105+
end
106+
private_class_method :apply_custom_assessment_weights
107+
108+
def self.validate_custom_assessment_weights_sum!(tab, entry, included_sum, included_any)
109+
return unless included_any
110+
return unless (included_sum * 100).round != (entry[:weight] * 100).round
111+
112+
tab.errors.add(:base, :custom_weights_mismatch)
113+
raise ActiveRecord::RecordInvalid, tab
114+
end
115+
116+
private
117+
118+
# Until the external-alignment design adds `external_assessment_id`, the only
119+
# contributor is the tab, so "exactly one" reduces to "tab present".
120+
def exactly_one_contributor
121+
errors.add(:tab, :blank) if tab_id.blank?
122+
end
123+
124+
def course_matches_contributor
125+
return if tab.nil? || course.nil?
126+
127+
errors.add(:course, :invalid) if tab.category.course_id != course_id
128+
end
129+
end

0 commit comments

Comments
 (0)