Skip to content

Commit 702f85e

Browse files
committed
feat(gradebook): implement gradebook with export
1 parent b81cc05 commit 702f85e

53 files changed

Lines changed: 2348 additions & 93 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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: self.class.key,
15+
icon: :gradebook,
16+
title: I18n.t('course.gradebook.component.sidebar_title'),
17+
type: :admin,
18+
weight: 4,
19+
path: course_gradebook_path(current_course)
20+
}
21+
]
22+
end
23+
end
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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+
tabs = @published_assessments.map(&:tab).uniq(&:id)
9+
@categories = tabs.map(&:category).uniq(&:id)
10+
@tabs = tabs
11+
@students = current_course.course_users.students.without_phantom_users.
12+
calculated(:experience_points).includes(:user).to_a
13+
student_ids = @students.map(&:user_id)
14+
@assessment_max_grades = Course::Assessment.max_grades(assessment_ids)
15+
@submissions = Course::Assessment::Submission.grade_summary(
16+
student_ids: student_ids,
17+
assessment_ids: assessment_ids
18+
)
19+
end
20+
21+
private
22+
23+
def authorize_read_gradebook!
24+
authorize! :read_gradebook, current_course
25+
end
26+
27+
def component
28+
current_component_host[:course_gradebook_component]
29+
end
30+
31+
def fetch_published_assessments
32+
current_course.assessments.
33+
published.
34+
includes(tab: :category).
35+
joins(tab: :category).
36+
reorder('course_assessment_categories.weight, course_assessment_tabs.weight, course_assessments.id')
37+
end
38+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# frozen_string_literal: true
2+
module Course::GradebookAbilityComponent
3+
include AbilityHost::Component
4+
5+
def define_permissions
6+
can :read_gradebook, Course, id: course.id if course_user&.staff?
7+
super
8+
end
9+
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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,27 @@ def self.on_dependent_status_change(answer)
323323
answer.submission.last_graded_time = Time.now
324324
end
325325

326+
# Returns an array of submission rows for the given students and assessments.
327+
# Each row has: student_id (creator_id), assessment_id, grade (float).
328+
# Only graded/published submissions are included.
329+
def self.grade_summary(student_ids:, assessment_ids:)
330+
return [] if student_ids.empty? || assessment_ids.empty?
331+
332+
find_by_sql(
333+
sanitize_sql_array([<<-SQL.squish, student_ids, assessment_ids])
334+
SELECT cas.creator_id AS student_id, cas.assessment_id,
335+
COALESCE(SUM(caa.grade), 0) AS grade
336+
FROM course_assessment_submissions cas
337+
JOIN course_assessment_answers caa ON caa.submission_id = cas.id
338+
WHERE cas.creator_id IN (?)
339+
AND cas.assessment_id IN (?)
340+
AND cas.workflow_state IN ('graded', 'published')
341+
AND caa.current_answer = TRUE
342+
GROUP BY cas.creator_id, cas.assessment_id
343+
SQL
344+
)
345+
end
346+
326347
private
327348

328349
# Queues the submission for auto grading, after the submission has changed to the submitted state.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# frozen_string_literal: true
2+
json.categories @categories do |cat|
3+
json.id cat.id
4+
json.title cat.title
5+
end
6+
7+
json.tabs @tabs do |tab|
8+
json.id tab.id
9+
json.title tab.title
10+
json.categoryId tab.category_id
11+
end
12+
13+
json.assessments @published_assessments do |assessment|
14+
json.id assessment.id
15+
json.title assessment.title
16+
json.tabId assessment.tab_id
17+
json.maxGrade @assessment_max_grades[assessment.id] || 0
18+
end
19+
20+
json.students @students do |course_user|
21+
json.id course_user.user_id
22+
json.name course_user.name
23+
json.email course_user.user.email
24+
json.externalId nil
25+
json.level course_user.level_number
26+
json.totalXp course_user.experience_points
27+
end
28+
29+
json.submissions @submissions do |sub|
30+
json.studentId sub.student_id
31+
json.assessmentId sub.assessment_id
32+
json.grade sub.grade.to_f
33+
end

client/app/api/course/Gradebook.ts

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

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/duplication/pages/Duplication/ItemsSelector/AchievementsSelector.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ import { FC } from 'react';
22
import { defineMessages } from 'react-intl';
33
import { ListSubheader, Typography } from '@mui/material';
44

5+
import BulkSelectors from 'course/duplication/components/BulkSelectors';
56
import TypeBadge from 'course/duplication/components/TypeBadge';
67
import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon';
78
import { selectDuplicationStore } from 'course/duplication/selectors';
89
import { actions } from 'course/duplication/store';
910
import { DuplicationAchievementData } from 'course/duplication/types';
1011
import { getAchievementBadgeUrl } from 'course/helper/achievements';
1112
import componentTranslations from 'course/translations';
12-
import BulkSelectors from 'course/duplication/components/BulkSelectors';
1313
import IndentedCheckbox from 'lib/components/core/IndentedCheckbox';
1414
import Thumbnail from 'lib/components/core/Thumbnail';
1515
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';

client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AssessmentsSelector.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { FC } from 'react';
22
import { defineMessages } from 'react-intl';
33
import { ListSubheader, Typography } from '@mui/material';
44

5+
import BulkSelectors from 'course/duplication/components/BulkSelectors';
56
import TypeBadge from 'course/duplication/components/TypeBadge';
67
import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon';
78
import {
@@ -15,7 +16,6 @@ import {
1516
DuplicationTabData,
1617
} from 'course/duplication/types';
1718
import componentTranslations from 'course/translations';
18-
import BulkSelectors from 'course/duplication/components/BulkSelectors';
1919
import IndentedCheckbox from 'lib/components/core/IndentedCheckbox';
2020
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
2121
import useTranslation from 'lib/hooks/useTranslation';

0 commit comments

Comments
 (0)