Skip to content

Commit 441d043

Browse files
committed
feat(gradebook): external assessment foundation (model, read serialization, inline grade entry)
Foundation layer for external assessments: data model + migrations, gradebook read serialization, and inline grade entry (update_grade). Management UI (panel, add/edit/delete), weight integration, and CSV import are introduced in later PRs of the stack.
1 parent 1c5b845 commit 441d043

38 files changed

Lines changed: 7030 additions & 3769 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# frozen_string_literal: true
2+
class Course::ExternalAssessmentsController < Course::ComponentController
3+
before_action :load_external_assessment, only: [:grades]
4+
5+
def grades
6+
authorize! :grade, @external_assessment
7+
# The gradebook keys students by user_id (see index/update_grade jbuilders), so the
8+
# `studentId` param is a user_id, not a course_user PK.
9+
course_user = current_course.course_users.find_by!(user_id: grade_params[:studentId])
10+
@grade = @external_assessment.external_assessment_grades.
11+
find_or_initialize_by(course_user: course_user)
12+
@grade.grade = normalized_grade(grade_params[:grade])
13+
@grade.save!
14+
render 'update_grade'
15+
rescue ActiveRecord::RecordNotUnique
16+
retry
17+
rescue ActiveRecord::RecordNotFound
18+
head :not_found
19+
rescue ActiveRecord::RecordInvalid => e
20+
render json: { errors: { base: e.message } }, status: :unprocessable_entity
21+
end
22+
23+
private
24+
25+
def component
26+
current_component_host[:course_gradebook_component]
27+
end
28+
29+
def load_external_assessment
30+
@external_assessment = Course::ExternalAssessment.for_course(current_course).find(params[:id])
31+
rescue ActiveRecord::RecordNotFound
32+
head :not_found
33+
end
34+
35+
def grade_params
36+
params.permit(:studentId, :grade)
37+
end
38+
39+
# Blank cell clears the grade to null (ungraded), never zero (decision #7).
40+
def normalized_grade(value)
41+
value.blank? ? nil : value
42+
end
43+
end

app/models/course.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ class Course < ApplicationRecord
5555
has_many :assessments, through: :assessment_categories
5656
has_many :gradebook_contributions, class_name: 'Course::Gradebook::Contribution',
5757
dependent: :destroy, inverse_of: :course
58+
has_many :external_assessments, class_name: 'Course::ExternalAssessment',
59+
inverse_of: :course, dependent: :destroy
5860
has_many :assessment_skills, class_name: 'Course::Assessment::Skill',
5961
dependent: :destroy
6062
has_many :assessment_skill_branches, class_name: 'Course::Assessment::SkillBranch',
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# frozen_string_literal: true
2+
# A gradebook component graded outside Coursemology (e.g. a midterm or final).
3+
# It is a first-class gradebook contributor, NOT a Course::Assessment: it never
4+
# touches attempts, EXP, statistics, todos, or the lesson plan. Its weight lives on
5+
# its course_gradebook_contributions row; its display grouping is synthesised by the
6+
# gradebook serializer (no real tab/category exists).
7+
class Course::ExternalAssessment < ApplicationRecord
8+
# Sentinel id for the serializer's synthetic "External Assessments" category.
9+
# Native categories are positive; externals and their synthetic grouping are negative.
10+
SYNTHETIC_CATEGORY_ID = -1
11+
SYNTHETIC_CATEGORY_TITLE = 'External Assessments'
12+
13+
validates :title, length: { maximum: 255 }, presence: true
14+
validates :title, uniqueness: { scope: :course_id }
15+
validates :maximum_grade, presence: true
16+
validates :maximum_grade, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
17+
validates :creator, presence: true
18+
validates :updater, presence: true
19+
20+
belongs_to :course, inverse_of: :external_assessments
21+
has_one :gradebook_contribution, class_name: 'Course::Gradebook::Contribution',
22+
inverse_of: :external_assessment, dependent: :destroy
23+
has_many :external_assessment_grades, class_name: 'Course::ExternalAssessmentGrade',
24+
inverse_of: :external_assessment, dependent: :destroy
25+
26+
scope :for_course, ->(course) { where(course_id: course.id) }
27+
28+
# The negative serialized id used by the synthetic tab AND the leaf assessment.
29+
def synthetic_tab_id
30+
-id
31+
end
32+
33+
# Creates an external assessment and its gradebook contribution in one transaction.
34+
# Raises ActiveRecord::RecordInvalid on a duplicate title within the course.
35+
# rubocop:disable Metrics/ParameterLists -- factory mirrors the model's columns; named kwargs are clearer than a struct
36+
def self.create_for_course!(course:, title:, maximum_grade:, weight: 0,
37+
floor_at_zero: true, cap_at_maximum: true)
38+
transaction do
39+
external = course.external_assessments.create!(
40+
title: title, maximum_grade: maximum_grade,
41+
floor_at_zero: floor_at_zero, cap_at_maximum: cap_at_maximum
42+
)
43+
Course::Gradebook::Contribution.create!(course: course, external_assessment: external,
44+
weight: weight, weight_mode: 'equal', keep_highest: 0)
45+
external
46+
end
47+
end
48+
# rubocop:enable Metrics/ParameterLists
49+
end
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# frozen_string_literal: true
2+
# One external grade for a (external assessment, course_user). The binding key is
3+
# course_user_id (the authoritative link to the person); imported_identifier is a
4+
# non-authoritative snapshot of the email/Student ID used at import (audit + upsert
5+
# mismatch detection), null for grades typed/edited inline.
6+
class Course::ExternalAssessmentGrade < ApplicationRecord
7+
validates :course_user, presence: true
8+
validates :grade, numericality: true, allow_nil: true
9+
validates :course_user_id, uniqueness: { scope: :external_assessment_id }
10+
validates :creator, presence: true
11+
validates :updater, presence: true
12+
13+
belongs_to :external_assessment, class_name: 'Course::ExternalAssessment',
14+
inverse_of: :external_assessment_grades
15+
belongs_to :course_user, inverse_of: :external_assessment_grades
16+
end

app/models/course/gradebook/contribution.rb

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@ class Course::Gradebook::Contribution < ApplicationRecord
88
belongs_to :course, inverse_of: :gradebook_contributions
99
belongs_to :tab, class_name: 'Course::Assessment::Tab',
1010
inverse_of: :gradebook_contribution, optional: true
11+
belongs_to :external_assessment, class_name: 'Course::ExternalAssessment',
12+
inverse_of: :gradebook_contribution, optional: true
1113

1214
validates :creator, presence: true
1315
validates :updater, presence: true
1416
validates :weight, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
1517
validates :weight_mode, presence: true
1618
validates :keep_highest, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
17-
validates :tab_id, uniqueness: true
19+
validates :tab_id, uniqueness: { allow_nil: true }
20+
validates :external_assessment_id, uniqueness: { allow_nil: true }
1821
validate :exactly_one_contributor
1922
validate :course_matches_contributor
2023
# Bulk-upserts tab contributions and their per-assessment contributions for a course.
@@ -27,13 +30,22 @@ class Course::Gradebook::Contribution < ApplicationRecord
2730
# @param updates [Array<Hash>] each { tab_id:, weight:, weight_mode:, keep_highest:,
2831
# excluded_assessment_ids: [Integer], assessment_weights: [{ assessment_id:, weight: }] }
2932
def self.bulk_update(course:, updates:)
33+
external_updates, tab_updates = updates.partition { |e| e[:tab_id].to_i < 0 }
34+
3035
course_tab_ids = course.assessment_tabs.pluck(:id).to_set
31-
updates.each { |e| raise ActiveRecord::RecordNotFound unless course_tab_ids.include?(e[:tab_id]) }
36+
tab_updates.each { |e| raise ActiveRecord::RecordNotFound unless course_tab_ids.include?(e[:tab_id]) }
37+
38+
external_ids = external_updates.map { |e| -e[:tab_id] }
39+
externals_by_id = course.external_assessments.where(id: external_ids).index_by(&:id)
40+
external_updates.each { |e| raise ActiveRecord::RecordNotFound unless externals_by_id.key?(-e[:tab_id]) }
3241

33-
tabs_by_id = Course::Assessment::Tab.where(id: updates.map { |e| e[:tab_id] }).
42+
tabs_by_id = Course::Assessment::Tab.where(id: tab_updates.map { |e| e[:tab_id] }).
3443
includes(:assessments).index_by(&:id)
3544

36-
transaction { updates.each { |entry| apply_entry(course, tabs_by_id, entry) } }
45+
transaction do
46+
tab_updates.each { |entry| apply_entry(course, tabs_by_id, entry) }
47+
external_updates.each { |entry| apply_external_entry(course, externals_by_id[-entry[:tab_id]], entry) }
48+
end
3749
end
3850

3951
# @api private
@@ -58,6 +70,16 @@ def self.apply_entry(course, tabs_by_id, entry)
5870
end
5971
private_class_method :apply_entry
6072

73+
# @api private
74+
def self.apply_external_entry(course, external, entry)
75+
contribution = find_or_initialize_by(external_assessment_id: external.id)
76+
contribution.tab = nil
77+
contribution.course = course
78+
contribution.assign_attributes(weight: entry[:weight], weight_mode: 'equal', keep_highest: 0)
79+
contribution.save!
80+
end
81+
private_class_method :apply_external_entry
82+
6183
# @api private
6284
def self.assessment_contribution_for(assessment)
6385
Course::Gradebook::AssessmentContribution.find_or_initialize_by(assessment_id: assessment.id)
@@ -117,15 +139,19 @@ def self.validate_custom_assessment_weights_sum!(tab, entry, included_sum, inclu
117139

118140
private
119141

120-
# Until the external-alignment design adds `external_assessment_id`, the only
121-
# contributor is the tab, so "exactly one" reduces to "tab present".
122142
def exactly_one_contributor
123-
errors.add(:tab, :blank) if tab_id.blank?
143+
return if [tab_id, external_assessment_id].compact.size == 1
144+
145+
errors.add(:base, :exactly_one_contributor)
124146
end
125147

126148
def course_matches_contributor
127-
return if tab.nil? || course.nil?
149+
return if course.nil?
128150

129-
errors.add(:course, :invalid) if tab.category.course_id != course_id
151+
contributor_course_id =
152+
if tab then tab.category.course_id
153+
elsif external_assessment then external_assessment.course_id
154+
end
155+
errors.add(:course, :invalid) if contributor_course_id && contributor_course_id != course_id
130156
end
131157
end

app/models/course_user.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ class CourseUser < ApplicationRecord
5050
inverse_of: :course_user, dependent: :destroy
5151
has_many :groups, through: :group_users, class_name: 'Course::Group', source: :group
5252
has_many :personal_times, class_name: 'Course::PersonalTime', inverse_of: :course_user, dependent: :destroy
53+
has_many :external_assessment_grades, class_name: 'Course::ExternalAssessmentGrade',
54+
inverse_of: :course_user, dependent: :destroy
5355
belongs_to :reference_timeline, class_name: 'Course::ReferenceTimeline', inverse_of: :course_users, optional: true
5456

5557
default_scope { where(deleted_at: nil) }
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# frozen_string_literal: true
2+
json.studentId @grade.course_user.user_id
3+
json.assessmentId(-@grade.external_assessment_id)
4+
json.grade @grade.grade&.to_f

app/views/course/gradebook/index.json.jbuilder

Lines changed: 75 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,71 @@
22
json.weightedViewEnabled @weighted_view_enabled
33
json.canManageWeights can?(:manage_gradebook_weights, current_course)
44

5-
json.categories @categories do |cat|
6-
json.id cat.id
7-
json.title cat.title
5+
json.categories do
6+
json.array!(@categories) do |cat|
7+
json.id cat.id
8+
json.title cat.title
9+
end
10+
if @external_assessments.any?
11+
json.child! do
12+
json.id Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID
13+
json.title Course::ExternalAssessment::SYNTHETIC_CATEGORY_TITLE
14+
end
15+
end
816
end
917

10-
json.tabs @tabs do |tab|
11-
json.id tab.id
12-
json.title tab.title
13-
json.categoryId tab.category_id
14-
if @weighted_view_enabled
15-
contribution = @tab_contributions[tab.id]
16-
json.gradebookWeight (contribution&.weight || 0).to_f
17-
json.weightMode(contribution&.weight_mode || 'equal')
18+
json.tabs do
19+
json.array!(@tabs) do |tab|
20+
json.id tab.id
21+
json.title tab.title
22+
json.categoryId tab.category_id
23+
if @weighted_view_enabled
24+
contribution = @tab_contributions[tab.id]
25+
json.gradebookWeight (contribution&.weight || 0).to_f
26+
json.weightMode(contribution&.weight_mode || 'equal')
27+
end
28+
end
29+
@external_assessments.each do |external|
30+
json.child! do
31+
json.id external.synthetic_tab_id
32+
json.title external.title
33+
json.categoryId Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID
34+
if @weighted_view_enabled
35+
contribution = @external_contributions[external.id]
36+
json.gradebookWeight (contribution&.weight || 0).to_f
37+
json.weightMode 'equal'
38+
end
39+
end
1840
end
1941
end
2042

21-
json.assessments @published_assessments do |assessment|
22-
json.id assessment.id
23-
json.title assessment.title
24-
json.tabId assessment.tab_id
25-
json.maxGrade @assessment_max_grades[assessment.id] || 0
26-
if @weighted_view_enabled
27-
contribution = @assessment_contributions[assessment.id]
28-
json.gradebookWeight contribution&.weight&.to_f
29-
json.gradebookExcluded(contribution&.excluded || false)
43+
json.assessments do
44+
json.array!(@published_assessments) do |assessment|
45+
json.id assessment.id
46+
json.title assessment.title
47+
json.tabId assessment.tab_id
48+
json.maxGrade @assessment_max_grades[assessment.id] || 0
49+
if @weighted_view_enabled
50+
contribution = @assessment_contributions[assessment.id]
51+
json.gradebookWeight contribution&.weight&.to_f
52+
json.gradebookExcluded(contribution&.excluded || false)
53+
end
54+
end
55+
@external_assessments.each do |external|
56+
json.child! do
57+
json.id(-external.id)
58+
json.title external.title
59+
json.tabId external.synthetic_tab_id
60+
json.maxGrade external.maximum_grade.to_f
61+
json.external true
62+
json.floorAtZero external.floor_at_zero
63+
json.capAtMaximum external.cap_at_maximum
64+
if @weighted_view_enabled
65+
contribution = @external_contributions[external.id]
66+
json.gradebookWeight contribution&.weight&.to_f
67+
json.gradebookExcluded false
68+
end
69+
end
3070
end
3171
end
3272

@@ -39,11 +79,21 @@ json.students @students do |course_user|
3979
json.totalXp course_user.experience_points
4080
end
4181

42-
json.submissions @submissions do |sub|
43-
json.submissionId sub.submission_id
44-
json.studentId sub.student_id
45-
json.assessmentId sub.assessment_id
46-
json.grade sub.grade&.to_f
82+
json.submissions do
83+
json.array!(@submissions) do |sub|
84+
json.submissionId sub.submission_id
85+
json.studentId sub.student_id
86+
json.assessmentId sub.assessment_id
87+
json.grade sub.grade&.to_f
88+
end
89+
@external_grades.each do |grade|
90+
json.child! do
91+
json.studentId grade.course_user.user_id
92+
json.assessmentId(-grade.external_assessment_id)
93+
json.grade grade.grade&.to_f
94+
end
95+
end
4796
end
4897

4998
json.gamificationEnabled current_course.gamified?
99+
json.userId current_user&.id

client/app/api/course/Gradebook.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { GradebookData, UpdateWeightsPayload } from 'types/course/gradebook';
1+
import {
2+
ExternalGradePayload,
3+
GradebookData,
4+
UpdateWeightsPayload,
5+
} from 'types/course/gradebook';
26

37
import { APIResponse } from 'api/types';
48

@@ -18,4 +22,14 @@ export default class GradebookAPI extends BaseCourseAPI {
1822
): APIResponse<UpdateWeightsPayload> {
1923
return this.client.patch(`${this.#urlPrefix}/weights`, payload);
2024
}
25+
26+
setExternalGrade(
27+
id: number,
28+
payload: { studentId: number; grade: number | null },
29+
): APIResponse<ExternalGradePayload> {
30+
return this.client.put(
31+
`${this.#urlPrefix}/external_assessments/${id}/grades`,
32+
payload,
33+
);
34+
}
2135
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import {
2+
buildAssessmentColumnId,
3+
parseAssessmentColumnId,
4+
} from '../components/buildAssessmentColumnIds';
5+
6+
describe('assessment column ids', () => {
7+
it('round-trips a positive (regular) assessment id', () => {
8+
expect(parseAssessmentColumnId(buildAssessmentColumnId(100))).toBe(100);
9+
});
10+
11+
it('round-trips a negative (external) assessment id', () => {
12+
expect(parseAssessmentColumnId(buildAssessmentColumnId(-5))).toBe(-5);
13+
});
14+
15+
it('returns null for a non-assessment column id', () => {
16+
expect(parseAssessmentColumnId('name')).toBeNull();
17+
expect(parseAssessmentColumnId('totalXp')).toBeNull();
18+
});
19+
20+
it('round-trips a zero id (falsy but valid)', () => {
21+
expect(parseAssessmentColumnId(buildAssessmentColumnId(0))).toBe(0);
22+
});
23+
24+
it('returns null for a malformed or unanchored asn- column id', () => {
25+
expect(parseAssessmentColumnId('asn-')).toBeNull();
26+
expect(parseAssessmentColumnId('asn-abc')).toBeNull();
27+
expect(parseAssessmentColumnId('asn-1.5')).toBeNull();
28+
expect(parseAssessmentColumnId('asn-12x')).toBeNull();
29+
expect(parseAssessmentColumnId('xasn-12')).toBeNull();
30+
});
31+
});

0 commit comments

Comments
 (0)