Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -19,4 +25,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
52 changes: 52 additions & 0 deletions app/controllers/course/gradebook_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ 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
assessment_ids = @published_assessments.pluck(:id)
load_weighted_view_contributions(assessment_ids) if @weighted_view_enabled
@assessment_max_grades = Course::Assessment.max_grades(assessment_ids)
@submissions = Course::Assessment::Submission.grade_summary(
student_ids: @students.map(&:user_id),
Expand All @@ -19,12 +21,62 @@ 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::Gradebook::TabContribution.bulk_update(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 load_weighted_view_contributions(assessment_ids)
@tab_contributions = Course::Gradebook::TabContribution.
where(tab_id: @tabs.map(&:id)).index_by(&:tab_id)
@assessment_contributions = Course::Gradebook::AssessmentContribution.
where(assessment_id: assessment_ids).index_by(&:assessment_id)
end

# Weights are stored as DECIMAL(5,2); round at the boundary so the echoed response
# matches the persisted value and the custom-weight sum check stays exact at 2dp.
def parse_weight_entry(entry)
{
tab_id: entry[:tabId].to_i,
weight: entry[:weight].to_f.round(2),
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.round(2) }
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 Down
4 changes: 3 additions & 1 deletion app/models/components/course/gradebook_ability_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ module Course::GradebookAbilityComponent
include AbilityHost::Component

def define_permissions
can :read_gradebook, Course, id: course.id if course_user&.staff?
can :read_gradebook, Course, id: course.id if course_user&.manager_or_owner?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see you have a separate PR open to restrict gradebook access to manager/owner.

However this is already doing that, does that mean the other PR is redundant now?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, will retire that PR accordingly.

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
10 changes: 7 additions & 3 deletions app/models/course.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true
class Course < ApplicationRecord
class Course < ApplicationRecord # rubocop:disable Metrics/ClassLength
include Course::SearchConcern
include Course::DuplicationConcern
include Course::CourseComponentsConcern
Expand Down Expand Up @@ -53,6 +53,8 @@ class Course < ApplicationRecord
dependent: :destroy, inverse_of: :course
has_many :assessment_tabs, source: :tabs, through: :assessment_categories
has_many :assessments, through: :assessment_categories
has_many :gradebook_contributions, class_name: 'Course::Gradebook::TabContribution',
dependent: :destroy, inverse_of: :course
has_many :assessment_skills, class_name: 'Course::Assessment::Skill',
dependent: :destroy
has_many :assessment_skill_branches, class_name: 'Course::Assessment::SkillBranch',
Expand Down Expand Up @@ -362,12 +364,14 @@ def nearest_forum_discussions(query_embedding, limit: 3)
# Set default values
def set_defaults
self.start_at ||= Time.zone.now.beginning_of_hour
self.end_at ||= self.start_at + 1.month
self.end_at ||= start_at + 1.month
self.default_reference_timeline ||= reference_timelines.new(default: true)
self.default_timeline_algorithm ||= 0 # 'fixed' algorithm

return unless creator && course_users.empty?
build_owner_course_user if creator && course_users.empty?
end

def build_owner_course_user
course_users.build(user: creator,
role: :owner,
creator: creator,
Expand Down
3 changes: 3 additions & 0 deletions app/models/course/assessment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ class Course::Assessment < ApplicationRecord
inverse_of: :assessment, dependent: :destroy
has_one :plagiarism_check, class_name: 'Course::Assessment::PlagiarismCheck',
inverse_of: :assessment, dependent: :destroy, autosave: true
has_one :gradebook_assessment_contribution,
class_name: 'Course::Gradebook::AssessmentContribution',
dependent: :destroy, inverse_of: :assessment
has_many :live_feedbacks, class_name: 'Course::Assessment::LiveFeedback',
inverse_of: :assessment, dependent: :destroy
has_many :links, class_name: 'Course::Assessment::Link', inverse_of: :assessment, dependent: :destroy
Expand Down
3 changes: 3 additions & 0 deletions app/models/course/assessment/tab.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ class Course::Assessment::Tab < ApplicationRecord

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

has_many :folders, class_name: 'Course::Material::Folder', through: :assessments,
inverse_of: nil

Expand Down
6 changes: 6 additions & 0 deletions app/models/course/gradebook.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true
module Course::Gradebook
def self.table_name_prefix
"#{Course.table_name.singularize}_gradebook_"
end
end
11 changes: 11 additions & 0 deletions app/models/course/gradebook/assessment_contribution.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class Course::Gradebook::AssessmentContribution < ApplicationRecord
belongs_to :assessment, class_name: 'Course::Assessment',
inverse_of: :gradebook_assessment_contribution

validates :creator, presence: true
validates :updater, presence: true
validates :weight, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
validates :excluded, inclusion: { in: [true, false] }
validates :assessment_id, uniqueness: true
end
122 changes: 122 additions & 0 deletions app/models/course/gradebook/tab_contribution.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# frozen_string_literal: true
class Course::Gradebook::TabContribution < ApplicationRecord
# `prefix: true` keeps Rails from generating a bare `equal?` predicate that would
# override Ruby's Object#equal? (identity, arity 1). Helpers become
# `weight_mode_equal?` etc.; the `weight_mode` reader still returns 'equal'/'custom'.
enum :weight_mode, { equal: 0, custom: 1 }, prefix: true

belongs_to :course, inverse_of: :gradebook_contributions
belongs_to :tab, class_name: 'Course::Assessment::Tab',
Comment thread
adi-herwana-nus marked this conversation as resolved.
inverse_of: :gradebook_contribution

validates :creator, presence: true
validates :updater, presence: true
validates :weight, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
validates :weight_mode, presence: true
validates :tab_id, uniqueness: true
validate :course_matches_contributor
# Bulk-upserts tab contributions and their per-assessment contributions for a course.
# Consumes the identical `updates` payload the controller parses today.
# Raises ActiveRecord::RecordNotFound if any tab_id/assessment_id is unknown, and
# ActiveRecord::RecordInvalid if validation fails or, for custom tabs, the included
# assessment weights do not sum (at 2dp) to the tab total; the transaction rolls 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.bulk_update(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 = Course::Assessment::Tab.where(id: updates.map { |e| e[:tab_id] }).
includes(:assessments).index_by(&:id)

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

# @api private
def self.apply_entry(course, tabs_by_id, entry)
tab = tabs_by_id[entry[:tab_id]]
mode = (entry[:weight_mode] || 'equal').to_s

contribution = find_or_initialize_by(tab_id: tab.id)
contribution.course = course
contribution.assign_attributes(weight: entry[:weight], weight_mode: mode)
contribution.save!

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
clear_assessment_weights(tab)
end
end
private_class_method :apply_entry

# @api private
def self.assessment_contribution_for(assessment)
Course::Gradebook::AssessmentContribution.find_or_initialize_by(assessment_id: assessment.id)
end
private_class_method :assessment_contribution_for

# @api private
# Membership applies in both modes: excluded ids -> true, the rest of the tab -> false.
def self.apply_assessment_exclusions(tab, excluded_ids)
excluded_set = excluded_ids.to_set
tab.assessments.each do |assessment|
ac = assessment_contribution_for(assessment)
ac.excluded = excluded_set.include?(assessment.id)
ac.save!
end
end
private_class_method :apply_assessment_exclusions

# @api private
def self.clear_assessment_weights(tab)
tab.assessments.each do |assessment|
ac = assessment_contribution_for(assessment)
ac.weight = nil
ac.save!
end
end
private_class_method :clear_assessment_weights

# @api private
def self.apply_custom_assessment_weights(tab, entry, excluded_ids)
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?

ac = assessment_contribution_for(assessment)
ac.weight = aw[:weight]
ac.save!
next if excluded_ids.include?(aw[:assessment_id])

included_sum += aw[:weight]
included_any = true
end
validate_custom_assessment_weights_sum!(tab, entry, included_sum, included_any)
end
private_class_method :apply_custom_assessment_weights

def self.validate_custom_assessment_weights_sum!(tab, entry, included_sum, included_any)
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

def course_matches_contributor
return if tab.nil? || course.nil?

errors.add(:course, :invalid) if tab.category.course_id != course_id
end
end
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
13 changes: 13 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,23 @@ json.tabs @tabs do |tab|
json.id tab.id
json.title tab.title
json.categoryId tab.category_id
if @weighted_view_enabled
contribution = @tab_contributions[tab.id]
json.gradebookWeight (contribution&.weight || 0).to_f
json.weightMode(contribution&.weight_mode || 'equal')
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
if @weighted_view_enabled
contribution = @assessment_contributions[assessment.id]
json.gradebookWeight contribution&.weight&.to_f
json.gradebookExcluded(contribution&.excluded || false)
end
end

json.students @students do |course_user|
Expand Down
Loading