app/controllers/scoreboards_controller.rb
##
# Each Assessment can have a scoreboard, which is modified with this controller
#
class ScoreboardsController < ApplicationController
before_action :set_assessment
before_action :set_assessment_breadcrumb
before_action :set_edit_assessment_breadcrumb, only: [:edit]
before_action :set_scoreboard, except: [:create]
action_auth_level :create, :instructor
def create
@scoreboard = Scoreboard.new do |s|
s.assessment_id = @assessment.id
s.banner = ""
s.colspec = ""
end
begin
@scoreboard.save!
flash[:success] = "Created scoreboard successfully."
rescue ActiveRecord::RecordInvalid => e
flash[:error] = "Unable to create scoreboard: #{e.message}"
end
redirect_to(edit_course_assessment_scoreboard_path(@course, @assessment))
end
action_auth_level :show, :student
def show
# It turns out that it's faster to just get everything and let the
# view handle it
problemQuery = "SELECT scores.score AS score,
submissions.version AS version,
submissions.created_at AS time,
submissions.autoresult AS autoresult,
problems.name AS problem_name,
submissions.course_user_datum_id AS course_user_datum_id
FROM scores,submissions,problems
WHERE submissions.assessment_id=#{@assessment.id}
AND submissions.id = scores.submission_id
AND problems.id = scores.problem_id
ORDER BY submissions.created_at ASC"
result = ActiveRecord::Base.connection.select_all(problemQuery)
@grades = {}
result.each do |row|
uid = row["course_user_datum_id"].to_i
unless @grades.key?(uid)
user = @course.course_user_data.find(uid)
next unless user.student? || @scoreboard.include_instructors
@grades[uid] = {}
@grades[uid][:nickname] = user.nickname
@grades[uid][:andrewID] = user.email
@grades[uid][:fullName] = user.full_name
@grades[uid][:problems] = {}
end
if @grades[uid][:version] != row["version"]
# MySQL returns a Time object, but SQLite returns a time-stamp string
row["time"] = Time.zone.parse(row["time"]) if row["time"].class != Time
@grades[uid][:time] = row["time"].in_time_zone
@grades[uid][:version] = row["version"].to_i
@grades[uid][:autoresult] = row["autoresult"]
end
@grades[uid][:problems][row["problem_name"]] = row["score"].to_f.round(1)
end
# Build the html for the scoreboard header
if @assessment.overwrites_method?(:scoreboardHeader)
@config_header = @assessment.config_module.scoreboardHeader
end
# Build the scoreboard entries for each student
@grades.values.each do |grade|
grade[:entry] = if @assessment.overwrites_method?(:createScoreboardEntry)
@assessment.config_module.createScoreboardEntry(
grade[:problems],
grade[:autoresult]
)
else
createScoreboardEntry(
grade[:problems],
grade[:autoresult]
)
end
rescue StandardError => e
# the scoreboard autoresult wasn't correctly formatted
# so we just return an empty array, which will be handled
# by the #show view code
grade[:entry] = []
if @cud.instructor?
# not using flash because could be too large of a message to pass
@errorMessage = "An error occurred while calling " \
"createScoreboardEntry(#{grade[:problems].inspect},\n"\
"#{grade[:autoresult]})"
@error = e
Rails.logger.error("Scoreboard error in #{@course.name}/#{@assessment.name}: #{@error}")
end
end
# We want to sort @grades.values instead of just @grades because @grades
# is a hash, and we only care about the values. This is also why we
# included the :nickname and :andrewID in the hash instead of looking
# them up based on the uid index. See
# http://greatwhite.ics.cs.cmu.edu/rubydoc/ruby/classes/Hash.html#M001122
# for more information.
# Catch errors along the way. An instructor will get the errors, a
# student will simply see an unsorted scoreboard.
begin
@sortedGrades = @grades.values.sort do |a, b|
# we add a default sort, in the case that the scoreboard entry could
# not be generated correctly (which results in a empty array)
if a[:entry].empty? || b[:entry].empty?
b[:entry].length <=> a[:entry].length
elsif @assessment.overwrites_method?(:scoreboardOrderSubmissions)
begin
@assessment.config_module.scoreboardOrderSubmissions(a, b)
rescue StandardError => e
if @cud.instructor?
# not using flash because could be too large of a message to pass
@errorMessage = "An error occurred while calling "\
"custom hook scoreboardOrderSubmissions(#{a.inspect},\n"\
"#{b.inspect})"
@error = e
Rails.logger.error("Custom scoreboard error in " \
"#{@course.name}/#{@assessment.name}: #{@error}")
render("scoreboards/edit") && return
else
flash[:error] =
"The scoreboard cannot be viewed due to an error. Please contact your instructor."
redirect_to(course_assessment_path(@course, @assessment)) && return
end
end
else
begin
scoreboardOrderSubmissions(a, b)
rescue StandardError => e
# this is a catch all if sorting in general just doesn't work
if @cud.instructor?
# not using flash because could be too large of a message to pass
@errorMessage = "An error occurred while calling "\
"scoreboardOrderSubmissions(#{a.inspect},\n"\
"#{b.inspect})"
@error = e
Rails.logger.error("scoreboard order error in #{@course.name}/#{@assessment.name}: " \
"#{@error}")
render("scoreboards/edit") && return
else
flash[:error] =
"The scoreboard cannot be viewed due to an error. Please contact your instructor."
redirect_to(course_assessment_path(@course, @assessment)) && return
end
end
end
end
rescue ArgumentError => e
if @cud.instructor?
# not using flash because could be too large of a message to pass
@errorMessage = "An error occurred while sorting "\
"submissions. Please make sure your scoreboard results are returned " \
"as an array of numbers. If you are using a custom scoreboard order, ensure your " \
"hook is functioning correctly."
@error = e
Rails.logger.error("scoreboard error in #{@course.name}/#{@assessment.name}: #{@error}")
render("scoreboards/edit") && return
else
flash[:error] =
"The scoreboard cannot be viewed due to an error. Please contact your instructor."
redirect_to(course_assessment_path(@course, @assessment)) && return
end
end
@colspec = nil
return unless @scoreboard.colspec.present?
# our scoreboard validations should ensure this will always work
@colspec = ActiveSupport::JSON.decode(@scoreboard.colspec)["scoreboard"]
end
action_auth_level :edit, :instructor
def edit
# Set the @column_summary instance variable for the view
@column_summary = emitColSpec(@scoreboard.colspec)
end
action_auth_level :update, :instructor
def update
if @scoreboard.update(scoreboard_params)
flash[:success] =
"Scoreboard saved."
else
flash[:error] =
@scoreboard.errors.full_messages.join("")
end
redirect_to(action: :edit) && return
end
action_auth_level :destroy, :instructor
def destroy
if @scoreboard.destroy
flash[:success] = "Scoreboard successfully deleted."
else
flash[:error] = "Unable to destroy scoreboard."
end
redirect_to(edit_course_assessment_path(@course, @assessment))
end
private
def set_scoreboard
@scoreboard = @assessment.scoreboard
redirect_to(course_assessment_path(@course, @assessment)) if @scoreboard.nil?
end
def scoreboard_params
params[:scoreboard].permit(:banner, :colspec, :include_instructors)
end
# emitColSpec - Emits a text summary of a column specification string.
def emitColSpec(colspec)
return "Empty column specification" if colspec.blank?
begin
# Quote JSON keys and values if they are not already quoted
quoted = colspec.gsub(/([a-zA-Z0-9]+):/, '"\1":').gsub(/:([a-zA-Z0-9]+)/, ':"\1"')
parsed = ActiveSupport::JSON.decode(quoted)
rescue StandardError
return "Invalid column spec"
end
# If there is no column spec, then use the default scoreboard
unless parsed
str = "TOTAL [desc] "
@assessment.problems.each do |problem|
str += "| #{problem.name.to_s.upcase}"
end
return str
end
# In this case there is a valid colspec
first = true
i = 0
parsed["scoreboard"].each do |hash|
if first
str = ""
first = false
else
str += " | "
end
str += hash["hdr"].to_s.upcase
str += hash["asc"] ? " [asc]" : " [desc]"
i += 1
end
str
end
#
# createScoreboardEntry - Create a row in the scoreboard. If the
# JSON autoresult string has a scoreboard array object, then use
# that as the template, otherwise use the default, which is the
# total score followed by the sum of the individual problem
# scores.
#
# Lab authors can override this function in the lab config file.
#
def createScoreboardEntry(scores, autoresult)
# If the assessment was not autograded or the scoreboard was
# not customized, then simply return the list of problem
# scores and their total.
if !autoresult ||
!@scoreboard ||
!@scoreboard.colspec ||
@scoreboard.colspec.blank?
# First we need to get the total score
total = 0.0
@assessment.problems.each do |problem|
total += scores[problem.name].to_f
end
# Now build the array of scores
entry = []
entry << total.round(1).to_s
@assessment.problems.each do |problem|
entry << scores[problem.name]
end
return entry
end
# At this point we have an autograded assessment with a
# customized scoreboard. Extract the scoreboard entry
# from the scoreboard array object in the JSON autoresult.
parsed = ActiveSupport::JSON.decode(autoresult)
# ensure that the parsed result is a hash with scoreboard field, where scoreboard is an array
if !parsed || !parsed.is_a?(Hash) || !parsed["scoreboard"] ||
!parsed["scoreboard"].is_a?(Array)
# If there is no autoresult for this student (typically
# because their code did not compile or it segfaulted and
# the instructor's autograder did not catch it) then
# raise an error, will be handled by caller
if @cud.instructor?
(flash.now[:error] = "Error parsing scoreboard for autograded assessment: " \
"scoreboard result is not an array. Please ensure that the autograder returns " \
"scoreboard results as an array.")
end
Rails.logger.error("Scoreboard error in #{@course.name}/#{@assessment.name}: " \
"Scoreboard result is not an array")
raise StandardError
end
parsed["scoreboard"]
end
#
# scoreboardOrderSubmissions - This function provides a "<=>"
# functionality to sort rows on a scoreboard. Row pairs are
# passed in (a,b) and must return -1, 0, or 1 depending if a is
# less than, equal to, or greater than b. Parms a and b are of
# form {:uid, :andrewID, :version, :time, :problems, :entry},
# where problems is a hash that contains keys for each problem id
# as well as for each problem name. An entry is the array
# returned from createScoreboardEntry.
#
# This function can be overwritten by the instructor in the lab
# config file.
#
def scoreboardOrderSubmissions(a, b)
# If the assessment is not autograded, or the instructor did
# not create a custom column spec, then revert to the default,
# which sorts by total problem, then by submission time.
if !@assessment.has_autograder? ||
!@scoreboard || @scoreboard.colspec.blank?
aSum = 0
bSum = 0
a[:problems].keys.each do |key|
aSum += a[:problems][key].to_f
end
b[:problems].keys.each do |key|
bSum += b[:problems][key].to_f
end
if bSum != aSum
bSum <=> aSum # descending
else
a[:time] <=> b[:time]
end
# In this case, we have an autograded lab for which the
# instructor has created a custom column specification. By
# default, we sort the columns from left to right
# in descending order. Lab authors can modify the default
# direction with the "asc" key in the column spec.
else
begin
parsed = ActiveSupport::JSON.decode(@scoreboard.colspec)
rescue StandardError => e
Rails.logger.error("Error in scoreboards controller updater: #{e.message}")
end
# Validations ensure that colspec is of the correct format
parsed["scoreboard"].each_with_index do |v, i|
ai = a[:entry][i].to_f
bi = b[:entry][i].to_f
next unless ai != bi
return ai <=> bi if v["asc"] # ascending
return bi <=> ai # descending otherwise
end
a[:time] <=> b[:time] # ascending by submission time to tiebreak
end
end
end