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
77 changes: 68 additions & 9 deletions app/controllers/course/gradebook_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true
class Course::GradebookController < Course::ComponentController
class Course::GradebookController < Course::ComponentController # rubocop:disable Metrics/ClassLength
before_action :authorize_read_gradebook!
before_action :preload_levels, only: [:index]

Expand All @@ -10,13 +10,11 @@ def index
@published_assessments = fetch_published_assessments
@categories, @tabs = fetch_categories_and_tabs
@students = fetch_students
@course_max_level = [current_course.levels.count - 1, 0].max
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),
assessment_ids: assessment_ids
)
load_weighted_view(assessment_ids) if @weighted_view_enabled
load_grades(assessment_ids)
@student_level_contributions = compute_student_level_contributions
end
end
end
Expand All @@ -25,7 +23,10 @@ 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) }
level_config = persist_level_contribution
response_body = { weights: serialize_weight_updates(updates) }
response_body[:levelContribution] = serialize_level_contribution(level_config) if level_config
render json: response_body
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound => e
render json: { errors: { base: e.message } }, status: :unprocessable_entity
end
Expand All @@ -36,13 +37,26 @@ def authorize_read_gradebook!
authorize! :read_gradebook, current_course
end

def load_weighted_view(assessment_ids)
@level_config = current_course.gradebook_level_config
load_weighted_view_contributions(assessment_ids)
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

def load_grades(assessment_ids)
@assessment_max_grades = Course::Assessment.max_grades(assessment_ids)
@submissions = Course::Assessment::Submission.grade_summary(
student_ids: @students.map(&:user_id),
assessment_ids: assessment_ids
)
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)
Expand All @@ -64,6 +78,42 @@ def update_weights_params
)
end

def persist_level_contribution
attrs = level_contribution_attrs
return nil if attrs.nil?

Course::Gradebook::LevelConfig.upsert_for(course: current_course, attrs: attrs)
end

def level_contribution_attrs
lc = params[:levelContribution]
return nil if lc.blank?

permitted = lc.permit(:enabled, :formula, :weight, :show, :clamp, formulaAst: {})
formula_ast = permitted[:formulaAst]
attrs = {
enabled: permitted[:enabled],
formula: permitted[:formula],
formula_ast: formula_ast.present? ? formula_ast.to_h : nil,
weight: permitted[:weight],
show: permitted[:show]
}
# Only forward :clamp when the client actually sent it, so LevelConfig.upsert_for's
# default-true fallback applies on omission rather than persisting NULL.
attrs[:clamp] = permitted[:clamp] if permitted.key?(:clamp)
attrs
end

def serialize_level_contribution(config)
{
enabled: config.enabled,
formula: config.formula,
weight: config.weight.to_f,
show: config.show,
clamp: config.clamp
}
end

def serialize_weight_updates(updates)
updates.map do |u|
entry = { tabId: u[:tab_id], weight: u[:weight], weightMode: u[:weight_mode].to_s,
Expand All @@ -88,7 +138,8 @@ def fetch_categories_and_tabs

def fetch_students
current_course.course_users.students.without_phantom_users.
calculated(:experience_points).includes(user: :emails).to_a
calculated(:experience_points).includes(user: :emails).to_a.
sort_by { |cu| cu.user.name }
end

def fetch_published_assessments
Expand All @@ -105,4 +156,12 @@ def fetch_published_assessments
def preload_levels
current_course.levels.to_a
end

def compute_student_level_contributions
return {} unless @level_config&.enabled

@students.to_h do |cu|
[cu.user_id, @level_config.evaluate_for(level: cu.level_number)]
end
end
end
2 changes: 2 additions & 0 deletions app/models/course.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ class Course < ApplicationRecord # rubocop:disable Metrics/ClassLength
has_many :assessments, through: :assessment_categories
has_many :gradebook_contributions, class_name: 'Course::Gradebook::TabContribution',
dependent: :destroy, inverse_of: :course
has_one :gradebook_level_config, class_name: 'Course::Gradebook::LevelConfig',
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
127 changes: 127 additions & 0 deletions app/models/course/gradebook/level_config.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# frozen_string_literal: true
class Course::Gradebook::LevelConfig < ApplicationRecord # rubocop:disable Metrics/ClassLength
belongs_to :course, inverse_of: :gradebook_level_config

validates :weight, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
validates :formula, presence: true, if: :enabled
validates :formula_ast, presence: true, if: :enabled
validates :course_id, uniqueness: true
validate :formula_ast_structure

# Upserts the singleton Level config for a course from a normalized attrs hash
# (symbol keys, snake_case). Raises ActiveRecord::RecordInvalid on validation failure.
def self.upsert_for(course:, attrs:)
config = find_or_initialize_by(course_id: course.id)
config.assign_attributes(
enabled: ActiveRecord::Type::Boolean.new.cast(attrs[:enabled]),
formula: attrs[:formula].to_s,
formula_ast: attrs[:formula_ast],
weight: attrs[:weight].to_f,
show: ActiveRecord::Type::Boolean.new.cast(attrs[:show]) == true,
clamp: attrs.key?(:clamp) ? ActiveRecord::Type::Boolean.new.cast(attrs[:clamp]) : true
)
config.save!
config
end

def evaluate_for(level:)
return nil unless enabled && formula_ast.present?

result = evaluate_node(formula_ast, level.to_f)
return nil unless result.is_a?(Numeric) && result.finite?

value = result.to_f
clamp ? value.clamp(0.0, weight.to_f) : value
rescue StandardError
nil
end

BINOPS = %w[+ - * /].freeze
CALL1_FNS = %w[floor ceil round].freeze
CALL2_FNS = %w[min max].freeze

def self.valid_ast?(node, depth = 0)
return false if depth > 20
return false unless node.is_a?(Hash) && node['type'].is_a?(String)

valid_node_type?(node, depth)
end

def self.valid_node_type?(node, depth)
case node['type']
when 'num' then node['value'].is_a?(Numeric)
when 'var' then node['name'] == 'level'
when 'neg' then valid_operand?(node, 'operand', depth)
when 'binop' then valid_binop?(node, depth)
when 'call1' then valid_call1?(node, depth)
when 'call2' then valid_call2?(node, depth)
else false
end
end

def self.valid_operand?(node, key, depth)
node.key?(key) && valid_ast?(node[key], depth + 1)
end

def self.valid_binop?(node, depth)
BINOPS.include?(node['op']) &&
valid_operand?(node, 'left', depth) &&
valid_operand?(node, 'right', depth)
end

def self.valid_call1?(node, depth)
CALL1_FNS.include?(node['fn']) && valid_operand?(node, 'arg', depth)
end

def self.valid_call2?(node, depth)
CALL2_FNS.include?(node['fn']) &&
valid_operand?(node, 'a', depth) &&
valid_operand?(node, 'b', depth)
end
private_class_method :valid_node_type?, :valid_operand?, :valid_binop?, :valid_call1?, :valid_call2?

private

def formula_ast_structure
return if formula_ast.blank?

errors.add(:formula_ast, 'has an invalid structure') unless self.class.valid_ast?(formula_ast)
end

def evaluate_node(node, level)
case node['type']
when 'num' then node['value'].to_f
when 'var' then level
when 'neg' then -evaluate_node(node['operand'], level)
when 'binop' then evaluate_binop(node, level)
when 'call1' then evaluate_call1(node, level)
when 'call2' then evaluate_call2(node, level)
end
end

def evaluate_binop(node, level)
l = evaluate_node(node['left'], level)
r = evaluate_node(node['right'], level)
case node['op']
when '+' then l + r
when '-' then l - r
when '*' then l * r
when '/' then (r == 0) ? Float::NAN : l / r
end
end

def evaluate_call1(node, level)
a = evaluate_node(node['arg'], level)
case node['fn']
when 'floor' then a.floor.to_f
when 'ceil' then a.ceil.to_f
when 'round' then a.round.to_f
end
end

def evaluate_call2(node, level)
a = evaluate_node(node['a'], level)
b = evaluate_node(node['b'], level)
(node['fn'] == 'min') ? [a, b].min : [a, b].max
end
end
19 changes: 19 additions & 0 deletions app/views/course/gradebook/index.json.jbuilder
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ json.students @students do |course_user|
json.externalId course_user.external_id
json.level course_user.level_number
json.totalXp course_user.experience_points
json.levelContribution @student_level_contributions[course_user.user_id]
end

json.submissions @submissions do |sub|
Expand All @@ -47,3 +48,21 @@ json.submissions @submissions do |sub|
end

json.gamificationEnabled current_course.gamified?

json.courseMaxLevel @course_max_level

json.levelContribution do
if @weighted_view_enabled && @level_config
json.enabled @level_config.enabled
json.formula @level_config.formula
json.weight @level_config.weight.to_f
json.show @level_config.show
json.clamp @level_config.clamp
else
json.enabled false
json.formula ''
json.weight 0
json.show false
json.clamp true
end
end
Loading