Skip to content
Open
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
35 changes: 35 additions & 0 deletions app/controllers/components/course/gradebook_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true
class Course::GradebookComponent < SimpleDelegator
include Course::ControllerComponentHost::Component

def self.display_name
'Gradebook'
end

def sidebar_items
items = []

if can?(:read_gradebook, current_course)
items << {
key: self.class.key,
icon: :gradebook,
title: I18n.t('course.gradebook.component.sidebar_title'),
type: :normal,
weight: 9,
path: course_gradebook_path(current_course)
}
end

if can?(:manage_gradebook_settings, current_course)
items << {
key: self.class.key,
title: I18n.t('course.gradebook.component.sidebar_title'),
type: :settings,
weight: 9,
path: course_admin_gradebook_path(current_course)
}
end

items
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
49 changes: 49 additions & 0 deletions app/controllers/course/gradebook_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# frozen_string_literal: true
class Course::GradebookController < Course::ComponentController
before_action :authorize_read_gradebook!

def index
respond_to do |format|
format.json do
@published_assessments = fetch_published_assessments
@categories, @tabs = fetch_categories_and_tabs
@students = fetch_students
assessment_ids = @published_assessments.pluck(:id)
@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
end
end

private

def authorize_read_gradebook!
authorize! :read_gradebook, current_course
end

def component
current_component_host[:course_gradebook_component]
end

def fetch_categories_and_tabs
tabs = @published_assessments.map(&:tab).uniq(&:id)
[tabs.map(&:category).uniq(&:id), tabs]
end

def fetch_students
current_course.levels.to_a # Warms the AR association cache to prevent N+1 in level_number
current_course.course_users.students.without_phantom_users.
calculated(:experience_points).includes(:user).to_a
end

def fetch_published_assessments
current_course.assessments.
published.
includes(tab: :category).
joins(tab: :category).
reorder('course_assessment_categories.weight, course_assessment_tabs.weight, course_assessments.id')
end
end
7 changes: 0 additions & 7 deletions app/controllers/course/statistics/aggregate_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,6 @@ def activity_get_help
@course_user_hash = current_course.course_users.index_by(&:user_id)
end

def download_score_summary
job = Course::Statistics::AssessmentsScoreSummaryDownloadJob.
perform_later(current_course, params[:assessment_ids]).job

render partial: 'jobs/submitted', locals: { job: job }
end

private

def sanitize_date_range(start_at_param, end_at_param)
Expand Down

This file was deleted.

13 changes: 13 additions & 0 deletions app/models/components/course/gradebook_ability_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true
module Course::GradebookAbilityComponent
include AbilityHost::Component

def define_permissions
can :read_gradebook, Course, id: course.id if course_user&.staff?
if course_user&.manager_or_owner?
can :manage_gradebook_weights, Course, id: course.id # Reserved for weights-editing endpoint in follow-on PR
can :manage_gradebook_settings, Course, id: course.id
end
super
end
end
16 changes: 16 additions & 0 deletions app/models/course/assessment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,22 @@ def self.use_relative_model_naming?
true
end

# Returns a hash of assessment_id => max_grade (sum of question maximum_grades).
def self.max_grades(assessment_ids)
return {} if assessment_ids.empty?

rows = find_by_sql(
sanitize_sql_array([<<-SQL.squish, assessment_ids])
SELECT cqa.assessment_id, COALESCE(SUM(caq.maximum_grade), 0) AS max_grade
FROM course_question_assessments cqa
JOIN course_assessment_questions caq ON caq.id = cqa.question_id
WHERE cqa.assessment_id IN (?)
GROUP BY cqa.assessment_id
SQL
)
rows.to_h { |row| [row.assessment_id, row.max_grade.to_f] }
end

def to_partial_path
'course/assessment/assessments/assessment'
end
Expand Down
21 changes: 21 additions & 0 deletions app/models/course/assessment/submission.rb
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,27 @@ def self.on_dependent_status_change(answer)
answer.submission.last_graded_time = Time.now
end

# Returns an array of submission rows for the given students and assessments.
# Each row has: student_id (creator_id), assessment_id, grade (float).
# Only graded/published submissions are included.
def self.grade_summary(student_ids:, assessment_ids:)
return [] if student_ids.empty? || assessment_ids.empty?

find_by_sql(
sanitize_sql_array([<<-SQL.squish, student_ids, assessment_ids])
SELECT cas.creator_id AS student_id, cas.assessment_id,
cas.id AS submission_id, SUM(caa.grade) AS grade
FROM course_assessment_submissions cas
JOIN course_assessment_answers caa ON caa.submission_id = cas.id
WHERE cas.creator_id IN (?)
AND cas.assessment_id IN (?)
AND cas.workflow_state IN ('graded', 'published')
AND caa.current_answer = TRUE
GROUP BY cas.creator_id, cas.assessment_id
SQL
)
end

private

# Queues the submission for auto grading, after the submission has changed to the submitted state.
Expand Down
5 changes: 5 additions & 0 deletions app/models/course/assessment/tab.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
class Course::Assessment::Tab < ApplicationRecord
validates :title, length: { maximum: 255 }, presence: true
validates :weight, numericality: { only_integer: true }, presence: true
validates :gradebook_weight,
numericality: { only_integer: true,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 100 },
presence: true
validates :creator, presence: true
validates :updater, presence: true
validates :category, presence: true
Expand Down
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

This file was deleted.

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
37 changes: 37 additions & 0 deletions app/views/course/gradebook/index.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# frozen_string_literal: true
json.categories @categories do |cat|
json.id cat.id
json.title cat.title
end

json.tabs @tabs do |tab|
json.id tab.id
json.title tab.title
json.categoryId tab.category_id
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
end

json.students @students do |course_user|
json.id course_user.user_id
json.name course_user.name
json.email course_user.user.email
json.externalId course_user.external_id
json.level course_user.level_number
json.totalXp course_user.experience_points
end

json.submissions @submissions do |sub|
json.studentId sub.student_id
json.assessmentId sub.assessment_id
json.submissionId sub.submission_id
json.grade sub.grade&.to_f
end

json.gamificationEnabled current_course.gamified?
json.userId current_user&.id
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ json.assessments @assessments do |assessment|
json.numAttempted @num_attempted_students_hash[assessment.id] || 0
json.numLate @num_late_students_hash[assessment.id] || 0
end

json.gradebookEnabled current_course.component_enabled?(Course::GradebookComponent) &&
can?(:read_gradebook, current_course)
23 changes: 23 additions & 0 deletions client/app/api/course/Admin/Gradebook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { AxiosResponse } from 'axios';
import {
GradebookSettingsData,
GradebookSettingsPostData,
} from 'types/course/admin/gradebook';

import BaseAdminAPI from './Base';

export default class GradebookAdminAPI extends BaseAdminAPI {
override get urlPrefix(): string {
return `${super.urlPrefix}/gradebook`;
}

index(): Promise<AxiosResponse<GradebookSettingsData>> {
return this.client.get(this.urlPrefix);
}

update(
data: GradebookSettingsPostData,
): Promise<AxiosResponse<GradebookSettingsData>> {
return this.client.patch(this.urlPrefix, data);
}
}
Loading