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

def self.display_name
'Gradebook'
end

def sidebar_items
return [] unless can?(:read_gradebook, current_course)

[
{
key: self.class.key,
icon: :gradebook,
title: I18n.t('course.gradebook.component.sidebar_title'),
type: :admin,
weight: 4,
path: course_gradebook_path(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
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
9 changes: 9 additions & 0 deletions app/models/components/course/gradebook_ability_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true
module Course::GradebookAbilityComponent
include AbilityHost::Component

def define_permissions
can :read_gradebook, Course, id: course.id if course_user&.staff?
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,
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
34 changes: 34 additions & 0 deletions app/views/course/gradebook/index.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# 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.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.grade sub.grade&.to_f
end

json.gamificationEnabled current_course.gamified?
15 changes: 15 additions & 0 deletions client/app/api/course/Gradebook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { GradebookData } from 'types/course/gradebook';

import { APIResponse } from 'api/types';

import BaseCourseAPI from './Base';

export default class GradebookAPI extends BaseCourseAPI {
get #urlPrefix(): string {
return `/courses/${this.courseId}/gradebook`;
}

index(): APIResponse<GradebookData> {
return this.client.get(this.#urlPrefix);
}
}
2 changes: 2 additions & 0 deletions client/app/api/course/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import DuplicationAPI from './Duplication';
import EnrolRequestsAPI from './EnrolRequests';
import ExperiencePointsRecordAPI from './ExperiencePointsRecord';
import ForumAPI from './Forum';
import GradebookAPI from './Gradebook';
import GroupsAPI from './Groups';
import LeaderboardAPI from './Leaderboard';
import LearningMapAPI from './LearningMap';
Expand Down Expand Up @@ -48,6 +49,7 @@ const CourseAPI = {
experiencePointsRecord: new ExperiencePointsRecordAPI(),
folders: new FoldersAPI(),
forum: ForumAPI,
gradebook: new GradebookAPI(),
groups: new GroupsAPI(),
leaderboard: new LeaderboardAPI(),
learningMap: new LearningMapAPI(),
Expand Down
Loading