Skip to content

Commit 291ea77

Browse files
committed
feat: add gradebook
1 parent 238eaa4 commit 291ea77

26 files changed

Lines changed: 1395 additions & 1 deletion

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: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
json.categoryId tab.category.id
8+
json.categoryTitle tab.category.title
9+
end
10+
11+
json.assessments @published_assessments do |assessment|
12+
json.id assessment.id
13+
json.title assessment.title
14+
json.tabId assessment.tab_id
15+
json.maxGrade @assessment_max_grades[assessment.id] || 0.0
16+
end
17+
18+
json.students @students do |course_user|
19+
user_id = course_user.user_id
20+
total_grade = 0.0
21+
total_max_grade = 0.0
22+
grades = {}
23+
24+
@published_assessments.each do |assessment|
25+
grade = @student_assessment_grades[[user_id, assessment.id]] || 0.0
26+
grades[assessment.id] = grade
27+
total_grade += grade
28+
total_max_grade += @assessment_max_grades[assessment.id] || 0.0
29+
end
30+
31+
json.id course_user.id
32+
json.name course_user.name
33+
json.grades grades
34+
json.totalGrade total_grade
35+
json.totalMaxGrade total_max_grade
36+
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(),

client/app/bundles/course/container/CourseContainer.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,10 @@ const CourseContainer = (): JSX.Element => {
5353
onChangeVisibility={setSidebarOpen}
5454
/>
5555

56-
<div ref={ref} className="flex min-h-full w-full flex-col overflow-auto">
56+
<div
57+
ref={ref}
58+
className="flex min-h-full w-full flex-col overflow-auto"
59+
>
5760
<div className="flex h-[4rem] w-full items-center">
5861
{!sidebarOpen && (
5962
<IconButton onClick={(): void => sidebarRef.current?.show()}>
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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: [
13+
{ id: 1, title: 'Assignments', categoryId: 10, categoryTitle: 'Missions' },
14+
],
15+
assessments: [{ id: 10, title: 'Assignment 1', tabId: 1, maxGrade: 100 }],
16+
students: [
17+
{
18+
id: 1,
19+
name: 'Alice Smith',
20+
grades: { '10': 80 },
21+
totalGrade: 80,
22+
totalMaxGrade: 100,
23+
},
24+
{
25+
id: 2,
26+
name: 'Bob Jones',
27+
grades: { '10': 60 },
28+
totalGrade: 60,
29+
totalMaxGrade: 100,
30+
},
31+
],
32+
};
33+
34+
describe('<GradebookIndex />', () => {
35+
it('renders all students initially', async () => {
36+
render(<GradebookIndex />, { state: { gradebook: gradebookState } });
37+
expect(await screen.findByText('Alice Smith')).toBeInTheDocument();
38+
expect(screen.getByText('Bob Jones')).toBeInTheDocument();
39+
});
40+
41+
it('filters students by name on search input', async () => {
42+
render(<GradebookIndex />, { state: { gradebook: gradebookState } });
43+
44+
const input = await screen.findByPlaceholderText('Search by student name');
45+
fireEvent.change(input, { target: { value: 'alice' } });
46+
47+
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
48+
expect(screen.queryByText('Bob Jones')).not.toBeInTheDocument();
49+
});
50+
51+
it('renders a show percentage toggle', async () => {
52+
render(<GradebookIndex />, { state: { gradebook: gradebookState } });
53+
expect(await screen.findByLabelText('Show percentage')).toBeInTheDocument();
54+
});
55+
56+
it('shows raw scores by default', async () => {
57+
render(<GradebookIndex />, { state: { gradebook: gradebookState } });
58+
59+
// Raw score is the default — Alice's grade shows as "80 / 100"
60+
expect(await screen.findAllByText('80 / 100')).not.toHaveLength(0);
61+
expect(screen.queryByText('80.00%')).not.toBeInTheDocument();
62+
});
63+
64+
it('switches from raw score to percentage when the toggle is clicked', async () => {
65+
render(<GradebookIndex />, { state: { gradebook: gradebookState } });
66+
67+
// Wait for default raw score rendering
68+
await screen.findAllByText('80 / 100');
69+
70+
// Click the "Show percentage" toggle
71+
fireEvent.click(screen.getByLabelText('Show percentage'));
72+
73+
// Percentage replaces the raw score
74+
expect(await screen.findAllByText('80.00%')).not.toHaveLength(0);
75+
expect(screen.queryByText('80 / 100')).not.toBeInTheDocument();
76+
});
77+
});

0 commit comments

Comments
 (0)