Skip to content
Draft
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
21 changes: 21 additions & 0 deletions app/controllers/components/course/gradebook_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# 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: :gradebook,
icon: :gradebook,
weight: 9,
path: course_gradebook_path(current_course)
}
]
end
end
34 changes: 34 additions & 0 deletions app/controllers/course/gradebook_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true
class Course::GradebookController < Course::ComponentController
before_action :authorize_read_gradebook!

def index
@published_assessments = fetch_published_assessments
assessment_ids = @published_assessments.pluck(:id)
@students = current_course.course_users.students.without_phantom_users.includes(:user)
student_ids = @students.pluck(:user_id)
@assessment_max_grades = Course::Assessment.max_grades(assessment_ids)
@student_assessment_grades = Course::Assessment::Submission.grade_summary(
student_ids: student_ids,
assessment_ids: assessment_ids
)
end

private

def authorize_read_gradebook!
authorize! :read_gradebook, current_course
end

def component
current_component_host[:course_gradebook_component]
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
15 changes: 15 additions & 0 deletions app/models/components/course/gradebook_ability_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true
module Course::GradebookAbilityComponent
include AbilityHost::Component

def define_permissions
allow_staff_read_gradebook if course_user&.staff?
super
end

private

def allow_staff_read_gradebook
can :read_gradebook, Course, id: course.id
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
20 changes: 20 additions & 0 deletions app/models/course/assessment/submission.rb
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,26 @@ def self.on_dependent_status_change(answer)
answer.submission.last_graded_time = Time.now
end

# Returns a hash of [creator_id, assessment_id] => grade for the given students and assessments.
# Only graded/published submissions are counted; submitted-but-ungraded contribute 0.
def self.grade_summary(student_ids:, assessment_ids:)
return {} if student_ids.empty? || assessment_ids.empty?

rows = find_by_sql(
sanitize_sql_array([<<-SQL.squish, student_ids, assessment_ids])
SELECT cas.creator_id, cas.assessment_id, COALESCE(SUM(caa.grade), 0) 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
)
rows.to_h { |row| [[row.creator_id, row.assessment_id], row.grade.to_f] }
end

private

# Queues the submission for auto grading, after the submission has changed to the submitted state.
Expand Down
36 changes: 36 additions & 0 deletions app/views/course/gradebook/index.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true
tabs = @published_assessments.map(&:tab).uniq(&:id)

json.tabs tabs do |tab|
json.id tab.id
json.title tab.title
json.categoryId tab.category.id
json.categoryTitle tab.category.title
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.0
end

json.students @students do |course_user|
user_id = course_user.user_id
total_grade = 0.0
total_max_grade = 0.0
grades = {}

@published_assessments.each do |assessment|
grade = @student_assessment_grades[[user_id, assessment.id]] || 0.0
grades[assessment.id] = grade
total_grade += grade
total_max_grade += @assessment_max_grades[assessment.id] || 0.0
end

json.id course_user.id
json.name course_user.name
json.grades grades
json.totalGrade total_grade
json.totalMaxGrade total_max_grade
end
14 changes: 14 additions & 0 deletions client/app/api/course/Gradebook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { AxiosResponse } from 'axios';
import { GradebookData } from 'types/course/gradebook';

import BaseCourseAPI from './Base';

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

index(): Promise<AxiosResponse<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
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { fireEvent, render, screen } from 'test-utils';

import GradebookIndex from '../pages/GradebookIndex';
import { GradebookState } from '../types';

jest.mock('../operations', () => ({
__esModule: true,
default: () => (): Promise<void> => Promise.resolve(),
}));

const gradebookState: GradebookState = {
tabs: [
{ id: 1, title: 'Assignments', categoryId: 10, categoryTitle: 'Missions' },
],
assessments: [{ id: 10, title: 'Assignment 1', tabId: 1, maxGrade: 100 }],
students: [
{
id: 1,
name: 'Alice Smith',
grades: { '10': 80 },
totalGrade: 80,
totalMaxGrade: 100,
},
{
id: 2,
name: 'Bob Jones',
grades: { '10': 60 },
totalGrade: 60,
totalMaxGrade: 100,
},
],
};

describe('<GradebookIndex />', () => {
it('renders all students initially', async () => {
render(<GradebookIndex />, { state: { gradebook: gradebookState } });
expect(await screen.findByText('Alice Smith')).toBeInTheDocument();
expect(screen.getByText('Bob Jones')).toBeInTheDocument();
});

it('filters students by name on search input', async () => {
render(<GradebookIndex />, { state: { gradebook: gradebookState } });

const input = await screen.findByPlaceholderText('Search by student name');
fireEvent.change(input, { target: { value: 'alice' } });

expect(screen.getByText('Alice Smith')).toBeInTheDocument();
expect(screen.queryByText('Bob Jones')).not.toBeInTheDocument();
});

it('renders a show percentage toggle', async () => {
render(<GradebookIndex />, { state: { gradebook: gradebookState } });
expect(await screen.findByLabelText('Show percentage')).toBeInTheDocument();
});

it('shows raw scores by default', async () => {
render(<GradebookIndex />, { state: { gradebook: gradebookState } });

// Raw score is the default — Alice's grade shows as "80 / 100"
expect(await screen.findAllByText('80 / 100')).not.toHaveLength(0);
expect(screen.queryByText('80.00%')).not.toBeInTheDocument();
});

it('switches from raw score to percentage when the toggle is clicked', async () => {
render(<GradebookIndex />, { state: { gradebook: gradebookState } });

// Wait for default raw score rendering
await screen.findAllByText('80 / 100');

// Click the "Show percentage" toggle
fireEvent.click(screen.getByLabelText('Show percentage'));

// Percentage replaces the raw score
expect(await screen.findAllByText('80.00%')).not.toHaveLength(0);
expect(screen.queryByText('80 / 100')).not.toBeInTheDocument();
});
});
Loading