Skip to content

Commit fbfdba8

Browse files
committed
feat: add gradebook
1 parent 500bad4 commit fbfdba8

25 files changed

Lines changed: 1194 additions & 0 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
class Course::GradebookComponent < SimpleDelegator
3+
include Course::ControllerComponentHost::Component
4+
5+
def self.display_name
6+
'Gradebook'
7+
end
8+
9+
def sidebar_items
10+
return [] unless can?(:read_gradebook, current_course)
11+
12+
[
13+
{
14+
key: :gradebook,
15+
icon: :gradebook,
16+
weight: 9,
17+
path: course_gradebook_path(current_course)
18+
}
19+
]
20+
end
21+
end
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# frozen_string_literal: true
2+
class Course::GradebookController < Course::ComponentController
3+
before_action :authorize_read_gradebook!
4+
5+
def index
6+
@published_assessments = fetch_published_assessments
7+
assessment_ids = @published_assessments.pluck(:id)
8+
@students = current_course.course_users.students.without_phantom_users.includes(:user)
9+
student_ids = @students.pluck(:user_id)
10+
@assessment_max_grades = Course::Assessment.max_grades(assessment_ids)
11+
@student_assessment_grades = Course::Assessment::Submission.grade_summary(
12+
student_ids: student_ids,
13+
assessment_ids: assessment_ids
14+
)
15+
end
16+
17+
private
18+
19+
def authorize_read_gradebook!
20+
authorize! :read_gradebook, current_course
21+
end
22+
23+
def component
24+
current_component_host[:course_gradebook_component]
25+
end
26+
27+
def fetch_published_assessments
28+
current_course.assessments.
29+
published.
30+
includes(:tab).
31+
joins(tab: :category).
32+
reorder('course_assessment_categories.weight, course_assessment_tabs.weight, course_assessments.id')
33+
end
34+
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
module Course::GradebookAbilityComponent
3+
include AbilityHost::Component
4+
5+
def define_permissions
6+
if course_user&.teaching_staff?
7+
allow_teaching_staff_read_gradebook
8+
elsif course_user&.staff?
9+
allow_staff_read_gradebook
10+
end
11+
super
12+
end
13+
14+
private
15+
16+
def allow_teaching_staff_read_gradebook
17+
can :read_gradebook, Course, id: course.id
18+
end
19+
20+
def allow_staff_read_gradebook
21+
can :read_gradebook, Course, id: course.id
22+
end
23+
end

app/models/course/assessment.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,22 @@ def self.use_relative_model_naming?
160160
true
161161
end
162162

163+
# Returns a hash of assessment_id => max_grade (sum of question maximum_grades).
164+
def self.max_grades(assessment_ids)
165+
return {} if assessment_ids.empty?
166+
167+
rows = find_by_sql(
168+
sanitize_sql_array([<<-SQL.squish, assessment_ids])
169+
SELECT cqa.assessment_id, COALESCE(SUM(caq.maximum_grade), 0) AS max_grade
170+
FROM course_question_assessments cqa
171+
JOIN course_assessment_questions caq ON caq.id = cqa.question_id
172+
WHERE cqa.assessment_id IN (?)
173+
GROUP BY cqa.assessment_id
174+
SQL
175+
)
176+
rows.to_h { |row| [row.assessment_id, row.max_grade.to_f] }
177+
end
178+
163179
def to_partial_path
164180
'course/assessment/assessments/assessment'
165181
end

app/models/course/assessment/submission.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,26 @@ def self.on_dependent_status_change(answer)
323323
answer.submission.last_graded_time = Time.now
324324
end
325325

326+
# Returns a hash of [creator_id, assessment_id] => grade for the given students and assessments.
327+
# Only graded/published submissions are counted; submitted-but-ungraded contribute 0.
328+
def self.grade_summary(student_ids:, assessment_ids:)
329+
return {} if student_ids.empty? || assessment_ids.empty?
330+
331+
rows = find_by_sql(
332+
sanitize_sql_array([<<-SQL.squish, student_ids, assessment_ids])
333+
SELECT cas.creator_id, cas.assessment_id, COALESCE(SUM(caa.grade), 0) AS grade
334+
FROM course_assessment_submissions cas
335+
JOIN course_assessment_answers caa ON caa.submission_id = cas.id
336+
WHERE cas.creator_id IN (?)
337+
AND cas.assessment_id IN (?)
338+
AND cas.workflow_state IN ('graded', 'published')
339+
AND caa.current_answer = TRUE
340+
GROUP BY cas.creator_id, cas.assessment_id
341+
SQL
342+
)
343+
rows.to_h { |row| [[row.creator_id, row.assessment_id], row.grade.to_f] }
344+
end
345+
326346
private
327347

328348
# Queues the submission for auto grading, after the submission has changed to the submitted state.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# frozen_string_literal: true
2+
tabs = @published_assessments.map(&:tab).uniq(&:id)
3+
4+
json.tabs tabs do |tab|
5+
json.id tab.id
6+
json.title tab.title
7+
end
8+
9+
json.assessments @published_assessments do |assessment|
10+
json.id assessment.id
11+
json.title assessment.title
12+
json.tabId assessment.tab_id
13+
json.maxGrade @assessment_max_grades[assessment.id] || 0.0
14+
end
15+
16+
json.students @students do |course_user|
17+
user_id = course_user.user_id
18+
total_grade = 0.0
19+
total_max_grade = 0.0
20+
grades = {}
21+
22+
@published_assessments.each do |assessment|
23+
grade = @student_assessment_grades[[user_id, assessment.id]] || 0.0
24+
grades[assessment.id] = grade
25+
total_grade += grade
26+
total_max_grade += @assessment_max_grades[assessment.id] || 0.0
27+
end
28+
29+
json.id course_user.id
30+
json.name course_user.name
31+
json.grades grades
32+
json.totalGrade total_grade
33+
json.totalMaxGrade total_max_grade
34+
end

client/app/api/course/Gradebook.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { AxiosResponse } from 'axios';
2+
import { GradebookData } from 'types/course/gradebook';
3+
4+
import BaseCourseAPI from './Base';
5+
6+
export default class GradebookAPI extends BaseCourseAPI {
7+
get #urlPrefix(): string {
8+
return `/courses/${this.courseId}/gradebook`;
9+
}
10+
11+
index(): Promise<AxiosResponse<GradebookData>> {
12+
return this.client.get(this.#urlPrefix);
13+
}
14+
}

client/app/api/course/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import DuplicationAPI from './Duplication';
1212
import EnrolRequestsAPI from './EnrolRequests';
1313
import ExperiencePointsRecordAPI from './ExperiencePointsRecord';
1414
import ForumAPI from './Forum';
15+
import GradebookAPI from './Gradebook';
1516
import GroupsAPI from './Groups';
1617
import LeaderboardAPI from './Leaderboard';
1718
import LearningMapAPI from './LearningMap';
@@ -48,6 +49,7 @@ const CourseAPI = {
4849
experiencePointsRecord: new ExperiencePointsRecordAPI(),
4950
folders: new FoldersAPI(),
5051
forum: ForumAPI,
52+
gradebook: new GradebookAPI(),
5153
groups: new GroupsAPI(),
5254
leaderboard: new LeaderboardAPI(),
5355
learningMap: new LearningMapAPI(),
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { fireEvent, render, screen } from 'test-utils';
2+
3+
import GradebookIndex from '../pages/GradebookIndex';
4+
import { GradebookState } from '../types';
5+
6+
jest.mock('../operations', () => ({
7+
__esModule: true,
8+
default: () => (): Promise<void> => Promise.resolve(),
9+
}));
10+
11+
const gradebookState: GradebookState = {
12+
tabs: [{ id: 1, title: 'Assignments' }],
13+
assessments: [{ id: 10, title: 'Assignment 1', tabId: 1, maxGrade: 100 }],
14+
students: [
15+
{
16+
id: 1,
17+
name: 'Alice Smith',
18+
grades: { '10': 80 },
19+
totalGrade: 80,
20+
totalMaxGrade: 100,
21+
},
22+
{
23+
id: 2,
24+
name: 'Bob Jones',
25+
grades: { '10': 60 },
26+
totalGrade: 60,
27+
totalMaxGrade: 100,
28+
},
29+
],
30+
};
31+
32+
describe('<GradebookIndex />', () => {
33+
it('renders all students initially', async () => {
34+
render(<GradebookIndex />, { state: { gradebook: gradebookState } });
35+
expect(await screen.findByText('Alice Smith')).toBeInTheDocument();
36+
expect(screen.getByText('Bob Jones')).toBeInTheDocument();
37+
});
38+
39+
it('filters students by name on search input', async () => {
40+
render(<GradebookIndex />, { state: { gradebook: gradebookState } });
41+
42+
const input = await screen.findByPlaceholderText('Search by student name');
43+
fireEvent.change(input, { target: { value: 'alice' } });
44+
45+
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
46+
expect(screen.queryByText('Bob Jones')).not.toBeInTheDocument();
47+
});
48+
49+
it('renders a raw score toggle', async () => {
50+
render(<GradebookIndex />, { state: { gradebook: gradebookState } });
51+
expect(await screen.findByLabelText('Show raw score')).toBeInTheDocument();
52+
});
53+
54+
it('switches existing rows from percentage to raw score when the toggle is clicked', async () => {
55+
render(<GradebookIndex />, { state: { gradebook: gradebookState } });
56+
57+
// Wait for table to render — Alice's grade shows as percentage by default.
58+
expect(await screen.findAllByText('80.00%')).not.toHaveLength(0);
59+
60+
// Click the toggle.
61+
fireEvent.click(screen.getByLabelText('Show raw score'));
62+
63+
// Existing rows must re-render — raw score replaces the percentage.
64+
expect(await screen.findAllByText('80 / 100')).not.toHaveLength(0);
65+
expect(screen.queryByText('80.00%')).not.toBeInTheDocument();
66+
});
67+
});

0 commit comments

Comments
 (0)