Skip to content

Commit b2a9464

Browse files
committed
feat: add gradebook
1 parent 3a8b328 commit b2a9464

27 files changed

Lines changed: 1202 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: :category).
31+
joins(tab: :category).
32+
reorder('course_assessment_categories.weight, course_assessment_tabs.weight, course_assessments.id')
33+
end
34+
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
module Course::GradebookAbilityComponent
3+
include AbilityHost::Component
4+
5+
def define_permissions
6+
allow_staff_read_gradebook if course_user&.staff?
7+
super
8+
end
9+
10+
private
11+
12+
def allow_staff_read_gradebook
13+
can :read_gradebook, Course, id: course.id
14+
end
15+
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/assessment/submission/containers/RubricPanel.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ const RubricPanel: FC<RubricPanelProps> = (props) => {
9696
categoryGrades={categoryGrades}
9797
question={question}
9898
readOnly={readOnly}
99+
setIsFirstRendering={setIsFirstRendering}
99100
/>
100101
))}
101102
</TableBody>

client/app/bundles/course/assessment/submission/containers/RubricPanelRow.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ interface RubricPanelRowProps {
3131
question: SubmissionQuestionData<'RubricBasedResponse'>;
3232
category: RubricBasedResponseCategoryQuestionData;
3333
categoryGrades: Record<number, AnswerRubricGradeData>;
34+
setIsFirstRendering: (isFirstRendering: boolean) => void;
3435
readOnly?: boolean;
3536
}
3637

0 commit comments

Comments
 (0)