Skip to content

Commit f8c70e8

Browse files
committed
feat(gradebook): add external assessments with grades and CSV import
- add ExternalAssessment/Grade models, CRUD + grade-set endpoints gated to teaching staff, scoped to the course - extend gradebook contributions to weight an external contributor (XOR validation), serialized as a synthetic negative-id grouping - CSV import wizard (define/upload/verify/conflict) with dry-run preview, upsert commit, and conflict/mismatch detection - FE store, operations, and inline add/rename/delete/grade/max editing for external columns in the gradebook table - surface external assessments in gradebook index JSON and route weight entries to external contributions - add controller, model, service, and component specs
1 parent 2efbb02 commit f8c70e8

58 files changed

Lines changed: 10344 additions & 4845 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.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# frozen_string_literal: true
2+
class Course::ExternalAssessmentImportsController < Course::ComponentController
3+
Service = Course::Gradebook::ExternalAssessmentImportService
4+
5+
def preview
6+
authorize! :manage_gradebook_weights, current_course
7+
@result = build_service.preview
8+
render 'preview'
9+
rescue Service::ImportError => e
10+
render json: { errors: e.payload }, status: :unprocessable_entity
11+
end
12+
13+
def create
14+
authorize! :manage_gradebook_weights, current_course
15+
@summary = build_service.commit(on_conflict: params[:onConflict])
16+
render 'create'
17+
rescue Service::ImportError => e
18+
render json: { errors: e.payload }, status: :unprocessable_entity
19+
end
20+
21+
private
22+
23+
def component
24+
current_component_host[:course_gradebook_component]
25+
end
26+
27+
def build_service
28+
Service.new(
29+
course: current_course,
30+
actor: current_user,
31+
components: import_params[:components].map do |c|
32+
{ name: c[:name], weightage: c[:weightage].to_i, maximum_grade: c[:maximumGrade].to_f }
33+
end,
34+
identifier_mode: import_params[:identifierMode],
35+
csv_data: import_params[:csvData]
36+
)
37+
end
38+
39+
def import_params
40+
params.permit(
41+
:identifierMode, :csvData, :onConflict,
42+
components: [:name, :weightage, :maximumGrade]
43+
)
44+
end
45+
end
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# frozen_string_literal: true
2+
class Course::ExternalAssessmentsController < Course::ComponentController
3+
before_action :load_external_assessment, only: [:update, :destroy, :grades]
4+
5+
def create
6+
authorize! :manage_gradebook_weights, current_course
7+
@external_assessment = Course::ExternalAssessment.create_for_course!(
8+
course: current_course,
9+
title: create_params[:title],
10+
maximum_grade: create_params[:maximumGrade]
11+
)
12+
render 'create'
13+
rescue ActiveRecord::RecordInvalid => e
14+
render json: { errors: { base: e.message } }, status: :unprocessable_entity
15+
end
16+
17+
def update
18+
authorize! :manage_gradebook_weights, current_course
19+
@external_assessment.update!(update_params_attrs)
20+
render 'update'
21+
rescue ActiveRecord::RecordInvalid => e
22+
render json: { errors: { base: e.message } }, status: :unprocessable_entity
23+
end
24+
25+
def destroy
26+
authorize! :manage_gradebook_weights, current_course
27+
@external_assessment.destroy!
28+
head :ok
29+
end
30+
31+
def grades
32+
authorize! :grade, @external_assessment
33+
course_user = current_course.course_users.find(grade_params[:courseUserId])
34+
@grade = @external_assessment.external_assessment_grades.
35+
find_or_initialize_by(course_user: course_user)
36+
@grade.grade = normalized_grade(grade_params[:grade])
37+
@grade.save!
38+
render 'update_grade'
39+
rescue ActiveRecord::RecordNotUnique
40+
retry
41+
rescue ActiveRecord::RecordNotFound
42+
head :not_found
43+
rescue ActiveRecord::RecordInvalid => e
44+
render json: { errors: { base: e.message } }, status: :unprocessable_entity
45+
end
46+
47+
private
48+
49+
def component
50+
current_component_host[:course_gradebook_component]
51+
end
52+
53+
def load_external_assessment
54+
@external_assessment = Course::ExternalAssessment.for_course(current_course).find(params[:id])
55+
rescue ActiveRecord::RecordNotFound
56+
head :not_found
57+
end
58+
59+
def create_params
60+
params.permit(:title, :maximumGrade)
61+
end
62+
63+
def update_params_attrs
64+
attrs = {}
65+
attrs[:title] = params[:title] if params.key?(:title)
66+
attrs[:maximum_grade] = params[:maximumGrade] if params.key?(:maximumGrade)
67+
attrs
68+
end
69+
70+
def grade_params
71+
params.permit(:courseUserId, :grade)
72+
end
73+
74+
# Blank cell clears the grade to null (ungraded), never zero (decision #7).
75+
def normalized_grade(value)
76+
value.blank? ? nil : value
77+
end
78+
end

app/controllers/course/gradebook_controller.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def index
1616
student_ids: @students.map(&:user_id),
1717
assessment_ids: assessment_ids
1818
)
19+
load_externals
1920
end
2021
end
2122
end
@@ -83,6 +84,15 @@ def fetch_categories_and_tabs
8384
[tabs.map(&:category).uniq(&:id), tabs]
8485
end
8586

87+
def load_externals
88+
@external_assessments = Course::ExternalAssessment.for_course(current_course).
89+
includes(:gradebook_contribution, external_assessment_grades: :course_user).to_a
90+
@external_grades = @external_assessments.flat_map(&:external_assessment_grades)
91+
@external_contributions = @external_assessments.
92+
index_by(&:id).
93+
transform_values(&:gradebook_contribution)
94+
end
95+
8696
def fetch_students
8797
current_course.levels.to_a
8898
current_course.course_users.students.without_phantom_users.

app/models/components/course/gradebook_ability_component.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ def define_permissions
66
can :read_gradebook, Course, id: course.id if course_user&.staff?
77
can :manage_gradebook_weights, Course, id: course.id if course_user&.manager_or_owner?
88
can :manage_gradebook_settings, Course, id: course.id if course_user&.manager_or_owner?
9+
can :grade, Course::ExternalAssessment if course_user&.teaching_staff?
910
super
1011
end
1112
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',

app/models/course/assessment/tab.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ class Course::Assessment::Tab < ApplicationRecord
1010
has_many :assessments, class_name: 'Course::Assessment', dependent: :destroy, inverse_of: :tab
1111
has_one :gradebook_contribution, class_name: 'Course::Gradebook::Contribution',
1212
dependent: :destroy, inverse_of: :tab
13-
1413
has_many :folders, class_name: 'Course::Material::Folder', through: :assessments,
1514
inverse_of: nil
1615

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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+
def self.create_for_course!(course:, title:, maximum_grade:, weight: 0)
36+
transaction do
37+
external = course.external_assessments.create!(title: title, maximum_grade: maximum_grade)
38+
Course::Gradebook::Contribution.create!(course: course, external_assessment: external,
39+
weight: weight, weight_mode: 'equal', keep_highest: 0)
40+
external
41+
end
42+
end
43+
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) }

0 commit comments

Comments
 (0)