Skip to content

Commit eda6ef5

Browse files
committed
feat(gradebook): add gradebook with column picker, CSV export, grade links & sorting
Introduce the course gradebook: a frozen-column table of students × assessments with a column picker, CSV export, and per-grade links into submissions. Gradebook table - Page with TanStack-backed table: pinned checkbox + Name columns, sticky header and Max Marks rows, frozen-column border seams that survive sticky scroll compositing. - Column picker (assessments grouped by tab/category) and CSV export of the selected columns; empty-state hint when no data columns are chosen. - External ID column, shown when any student has one. - Grade cells link to the student's submission; a dismissible GradeLinkHint banner explains the affordance (persisted per-user via useDismissibleOnce). - "Search students" global search. - Default sort with name ascending, null/undefined at bottom of sort regardless of order Shared table builder - getColumnCanGlobalFilter is gated on column visibility ("search what you see"): hiding a column via the picker removes it from search, and the nullable-first-row type sniff is bypassed. Affects all TanStack tables. Backend - Gradebook controller, ability and course component; index JSON serializes students, assessments, submissions (with submissionId) and gamification. - Submission grade query also selects the submission id for grade links. - Remove the redundant ScoreAssessmentSummary download from Statistics.
1 parent 46ab859 commit eda6ef5

76 files changed

Lines changed: 4564 additions & 513 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.circleci/config.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ commands:
119119
command: yarn build:test
120120
environment:
121121
AVAILABLE_CPUS: 4
122+
NODE_OPTIONS: --max-old-space-size=4096
122123

123124
- save_cache:
124125
paths:
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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+
type: :normal,
17+
weight: 9,
18+
path: course_gradebook_path(current_course)
19+
}
20+
]
21+
end
22+
end
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# frozen_string_literal: true
2+
class Course::GradebookController < Course::ComponentController
3+
before_action :authorize_read_gradebook!
4+
before_action :preload_levels, only: [:index]
5+
6+
def index
7+
respond_to do |format|
8+
format.json do
9+
@published_assessments = fetch_published_assessments
10+
@categories, @tabs = fetch_categories_and_tabs
11+
@students = fetch_students
12+
assessment_ids = @published_assessments.pluck(:id)
13+
@assessment_max_grades = Course::Assessment.max_grades(assessment_ids)
14+
@submissions = Course::Assessment::Submission.grade_summary(
15+
student_ids: @students.map(&:user_id),
16+
assessment_ids: assessment_ids
17+
)
18+
end
19+
end
20+
end
21+
22+
private
23+
24+
def authorize_read_gradebook!
25+
authorize! :read_gradebook, current_course
26+
end
27+
28+
def component
29+
current_component_host[:course_gradebook_component]
30+
end
31+
32+
def fetch_categories_and_tabs
33+
tabs = @published_assessments.map(&:tab).uniq(&:id)
34+
[tabs.map(&:category).uniq(&:id), tabs]
35+
end
36+
37+
def fetch_students
38+
current_course.course_users.students.without_phantom_users.
39+
calculated(:experience_points).includes(user: :emails).to_a
40+
end
41+
42+
def fetch_published_assessments
43+
current_course.assessments.
44+
published.
45+
includes(tab: :category).
46+
joins(tab: :category).
47+
reorder('course_assessment_categories.weight, course_assessment_tabs.weight, course_assessments.id')
48+
end
49+
50+
# Pre-loads course levels to avoid N+1 queries when each course_user.level_number is rendered.
51+
# level_number is derived, not an association (it buckets EXP against course.levels), so it
52+
# can't be added to fetch_students' includes. See Course::LevelsConcern#level_for.
53+
def preload_levels
54+
current_course.levels.to_a
55+
end
56+
end

app/controllers/course/statistics/aggregate_controller.rb

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,6 @@ def activity_get_help
4747
@course_user_hash = current_course.course_users.index_by(&:user_id)
4848
end
4949

50-
def download_score_summary
51-
job = Course::Statistics::AssessmentsScoreSummaryDownloadJob.
52-
perform_later(current_course, params[:assessment_ids]).job
53-
54-
render partial: 'jobs/submitted', locals: { job: job }
55-
end
56-
5750
private
5851

5952
def sanitize_date_range(start_at_param, end_at_param)

app/jobs/course/statistics/assessments_score_summary_download_job.rb

Lines changed: 0 additions & 17 deletions
This file was deleted.
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+
cas.id AS submission_id, SUM(caa.grade) 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, cas.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.

app/services/course/statistics/assessments_score_summary_download_service.rb

Lines changed: 0 additions & 82 deletions
This file was deleted.

app/views/course/courses/sidebar.json.jbuilder

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ json.courseUrl course_path(current_course)
44
json.courseLogoUrl url_to_course_logo(current_course)
55
json.courseUserUrl url_to_user_or_course_user(current_course, current_course_user)
66
json.userName current_user&.name
7+
json.userId current_user&.id
78

89
if current_course_user.present? && can?(:read, current_course)
910
json.courseUserName current_course_user.name

0 commit comments

Comments
 (0)