Skip to content

Commit ade1ddf

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 f3cc59a commit ade1ddf

66 files changed

Lines changed: 7267 additions & 37 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
[
@@ -20,4 +26,17 @@ def sidebar_items
2026
}
2127
]
2228
end
29+
30+
def settings_sidebar_items
31+
return [] unless can?(:manage_gradebook_settings, current_course)
32+
33+
[
34+
{
35+
key: self.class.key,
36+
type: :settings,
37+
weight: 14,
38+
path: course_admin_gradebook_path(current_course)
39+
}
40+
]
41+
end
2342
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: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ class Course::GradebookController < Course::ComponentController
55
def index
66
respond_to do |format|
77
format.json do
8+
@weighted_view_enabled = @settings.weighted_view_enabled
89
@published_assessments = fetch_published_assessments
910
@categories, @tabs = fetch_categories_and_tabs
1011
@students = fetch_students
1112
assessment_ids = @published_assessments.pluck(:id)
13+
load_weighted_view_contributions(assessment_ids) if @weighted_view_enabled
1214
@assessment_max_grades = Course::Assessment.max_grades(assessment_ids)
1315
@submissions = Course::Assessment::Submission.grade_summary(
1416
student_ids: @students.map(&:user_id),
@@ -18,12 +20,60 @@ def index
1820
end
1921
end
2022

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

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

38+
def load_weighted_view_contributions(assessment_ids)
39+
@tab_contributions = Course::Gradebook::Contribution.
40+
where(tab_id: @tabs.map(&:id)).index_by(&:tab_id)
41+
@assessment_contributions = Course::Gradebook::AssessmentContribution.
42+
where(assessment_id: assessment_ids).index_by(&:assessment_id)
43+
end
44+
45+
def parse_weight_entry(entry)
46+
{
47+
tab_id: entry[:tabId].to_i,
48+
weight: entry[:weight].to_f,
49+
weight_mode: entry[:weightMode] || 'equal',
50+
excluded_assessment_ids: (entry[:excludedAssessmentIds] || []).map(&:to_i),
51+
assessment_weights: (entry[:assessmentWeights] || []).map do |aw|
52+
{ assessment_id: aw[:assessmentId].to_i, weight: aw[:weight].to_f }
53+
end
54+
}
55+
end
56+
57+
def update_weights_params
58+
params.permit(
59+
weights: [:tabId, :weight, :weightMode,
60+
excludedAssessmentIds: [], assessmentWeights: [:assessmentId, :weight]]
61+
)
62+
end
63+
64+
def serialize_weight_updates(updates)
65+
updates.map do |u|
66+
entry = { tabId: u[:tab_id], weight: u[:weight], weightMode: u[:weight_mode].to_s,
67+
excludedAssessmentIds: u[:excluded_assessment_ids] }
68+
if u[:weight_mode].to_s == 'custom'
69+
entry[:assessmentWeights] = u[:assessment_weights].map do |aw|
70+
{ assessmentId: aw[:assessment_id], weight: aw[:weight] }
71+
end
72+
end
73+
entry
74+
end
75+
end
76+
2777
def component
2878
current_component_host[:course_gradebook_component]
2979
end
@@ -36,7 +86,8 @@ def fetch_categories_and_tabs
3686
def fetch_students
3787
current_course.levels.to_a
3888
current_course.course_users.students.without_phantom_users.
39-
calculated(:experience_points).includes(:user).to_a
89+
calculated(:experience_points).includes(:user).to_a.
90+
sort_by { |cu| cu.user.name }
4091
end
4192

4293
def fetch_published_assessments

app/models/components/course/gradebook_ability_component.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ module Course::GradebookAbilityComponent
44

55
def define_permissions
66
can :read_gradebook, Course, id: course.id if course_user&.staff?
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: 14 additions & 1 deletion
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',
@@ -361,11 +363,22 @@ def nearest_forum_discussions(query_embedding, limit: 3)
361363

362364
# Set default values
363365
def set_defaults
366+
set_default_times
367+
set_default_timeline
368+
build_creator_course_user
369+
end
370+
371+
def set_default_times
364372
self.start_at ||= Time.zone.now.beginning_of_hour
365-
self.end_at ||= self.start_at + 1.month
373+
self.end_at ||= start_at + 1.month
374+
end
375+
376+
def set_default_timeline
366377
self.default_reference_timeline ||= reference_timelines.new(default: true)
367378
self.default_timeline_algorithm ||= 0 # 'fixed' algorithm
379+
end
368380

381+
def build_creator_course_user
369382
return unless creator && course_users.empty?
370383

371384
course_users.build(user: creator,

app/models/course/assessment.rb

Lines changed: 4 additions & 1 deletion
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
@@ -211,7 +214,7 @@ def update_randomization(params)
211214
# - The assessment don't have any submissions.
212215
# - Switching from autograded mode to manually graded mode.
213216
def allow_mode_switching?
214-
submissions.count == 0 || autograded?
217+
submissions.none? || autograded?
215218
end
216219

217220
# @override ConditionalInstanceMethods#permitted_for!

app/models/course/assessment/tab.rb

Lines changed: 21 additions & 10 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

@@ -33,17 +36,25 @@ def other_tabs_remaining?
3336
end
3437

3538
def initialize_duplicate(duplicator, other)
36-
self.category = if duplicator.duplicated?(other.category)
37-
duplicator.duplicate(other.category)
38-
else
39-
duplicator.options[:destination_course].assessment_categories.first
40-
end
41-
assessments <<
42-
other.assessments.select { |assessment| duplicator.duplicated?(assessment) }.map do |assessment|
43-
duplicator.duplicate(assessment).tap do |duplicate_assessment|
44-
duplicate_assessment.folder.parent = category.folder
45-
end
39+
self.category = duplicated_category_for(duplicator, other)
40+
41+
assessments << duplicated_assessments_for(duplicator, other)
42+
end
43+
44+
def duplicated_category_for(duplicator, other)
45+
return duplicator.duplicate(other.category) if duplicator.duplicated?(other.category)
46+
47+
duplicator.options[:destination_course].assessment_categories.first
48+
end
49+
50+
def duplicated_assessments_for(duplicator, other)
51+
other.assessments.filter_map do |assessment|
52+
next unless duplicator.duplicated?(assessment)
53+
54+
duplicator.duplicate(assessment).tap do |duplicate_assessment|
55+
duplicate_assessment.folder.parent = category.folder
4656
end
57+
end
4758
end
4859

4960
private

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

0 commit comments

Comments
 (0)