Skip to content

Commit 9fab10f

Browse files
committed
feat(gradebook): external assessment management — panel, add/edit/delete, reorder, weight integration
Adds the management surface on top of the read-only foundation: - Manage panel (launched from the gradebook + weighted toolbars) with add, edit, and delete prompts; drag-and-drop reordering of external columns. - Weighted-view integration: external rows in Configure Weights (name + single weight, no mode/expand), and flat external grouping in the column picker. - Store reducers + thunks for create/update/delete/reorder; API + types. - Backend: create/update/destroy/reorder actions with jbuilders; a per-course `position` column (append-on-create, reorder! rewrites order); routes.
1 parent e6fde8b commit 9fab10f

40 files changed

Lines changed: 4033 additions & 73 deletions

app/controllers/course/external_assessments_controller.rb

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,46 @@
11
# frozen_string_literal: true
22
class Course::ExternalAssessmentsController < Course::ComponentController
3-
before_action :load_external_assessment, only: [:grades]
3+
before_action :load_external_assessment, only: [:update, :destroy, :grades]
4+
5+
def create
6+
authorize! :manage_gradebook_weights, current_course
7+
@weighted_view_enabled = gradebook_settings.weighted_view_enabled
8+
@external_assessment = Course::ExternalAssessment.create_for_course!(
9+
course: current_course,
10+
title: create_params[:title],
11+
maximum_grade: create_params[:maximumGrade],
12+
weight: create_weight,
13+
floor_at_zero: bound_flag(:floorAtZero, default: true),
14+
cap_at_maximum: bound_flag(:capAtMaximum, default: true)
15+
)
16+
render 'create'
17+
rescue ActiveRecord::RecordInvalid => e
18+
render json: { errors: { base: e.message } }, status: :unprocessable_entity
19+
end
20+
21+
def update
22+
authorize! :manage_gradebook_weights, current_course
23+
@weighted_view_enabled = gradebook_settings.weighted_view_enabled
24+
@external_assessment.update!(update_params_attrs)
25+
update_weight if @weighted_view_enabled && params.key?(:weight)
26+
render 'update'
27+
rescue ActiveRecord::RecordInvalid => e
28+
render json: { errors: { base: e.message } }, status: :unprocessable_entity
29+
end
30+
31+
def destroy
32+
authorize! :manage_gradebook_weights, current_course
33+
@external_assessment.destroy!
34+
head :ok
35+
end
36+
37+
def reorder
38+
authorize! :manage_gradebook_weights, current_course
39+
Course::ExternalAssessment.reorder!(course: current_course, ordered_ids: reorder_params)
40+
head :ok
41+
rescue ArgumentError
42+
head :unprocessable_entity
43+
end
444

545
def grades
646
authorize! :manage_gradebook_weights, current_course
@@ -26,12 +66,44 @@ def component
2666
current_component_host[:course_gradebook_component]
2767
end
2868

69+
def gradebook_settings
70+
@gradebook_settings ||= Course::Settings::GradebookComponent.new(component)
71+
end
72+
2973
def load_external_assessment
3074
@external_assessment = Course::ExternalAssessment.for_course(current_course).find(params[:id])
3175
rescue ActiveRecord::RecordNotFound
3276
head :not_found
3377
end
3478

79+
def create_params
80+
params.permit(:title, :maximumGrade, :weight, :floorAtZero, :capAtMaximum)
81+
end
82+
83+
def create_weight
84+
@weighted_view_enabled ? (create_params[:weight].presence || 0).to_f : 0
85+
end
86+
87+
def update_weight
88+
@external_assessment.gradebook_contribution&.update!(weight: (params[:weight].presence || 0).to_f)
89+
end
90+
91+
def update_params_attrs
92+
attrs = {}
93+
attrs[:title] = params[:title] if params.key?(:title)
94+
attrs[:maximum_grade] = params[:maximumGrade] if params.key?(:maximumGrade)
95+
attrs[:floor_at_zero] = bound_flag(:floorAtZero, default: true) if params.key?(:floorAtZero)
96+
attrs[:cap_at_maximum] = bound_flag(:capAtMaximum, default: true) if params.key?(:capAtMaximum)
97+
attrs
98+
end
99+
100+
# Coerce a string/bool HTTP param into a Ruby boolean (defaults when absent).
101+
def bound_flag(key, default:)
102+
return default unless params.key?(key)
103+
104+
ActiveRecord::Type::Boolean.new.cast(params[key])
105+
end
106+
35107
def grade_params
36108
params.permit(:studentId, :grade)
37109
end
@@ -40,4 +112,8 @@ def grade_params
40112
def normalized_grade(value)
41113
value.blank? ? nil : value
42114
end
115+
116+
def reorder_params
117+
params.require(:orderedIds).map(&:to_i)
118+
end
43119
end

app/controllers/course/gradebook_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ def fetch_categories_and_tabs
160160
end
161161

162162
def load_externals
163-
@external_assessments = Course::ExternalAssessment.for_course(current_course).
163+
@external_assessments = Course::ExternalAssessment.for_course(current_course).order(:position).
164164
includes(:gradebook_contribution, external_assessment_grades: :course_user).to_a
165165
@external_grades = @external_assessments.flat_map(&:external_assessment_grades)
166166
@external_contributions = @external_assessments.

app/models/course/external_assessment.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,37 @@ class Course::ExternalAssessment < ApplicationRecord
2020
belongs_to :course, inverse_of: :external_assessments
2121
has_one :gradebook_contribution, class_name: 'Course::Gradebook::ExternalContribution',
2222
inverse_of: :external_assessment, dependent: :destroy
23+
# delete_all (not destroy): grades carry no destroy callbacks, so destroying the
24+
# parent would otherwise fire one SELECT+DELETE per grade (N+1). delete_all removes
25+
# them in a single statement. Rails must issue it — the DB FK has no ON DELETE CASCADE.
2326
has_many :external_assessment_grades, class_name: 'Course::ExternalAssessmentGrade',
2427
inverse_of: :external_assessment, dependent: :delete_all
2528

2629
scope :for_course, ->(course) { where(course_id: course.id) }
2730

31+
before_create :assign_default_position
32+
33+
# Next free position for the course (positions are 0-based, per course, not
34+
# required to be unique). Drives append-at-end for both manual add and import.
35+
def self.next_position(course)
36+
(for_course(course).maximum(:position) || -1) + 1
37+
end
38+
39+
# Rewrites positions to match ordered_ids (the canonical gradebook order). The
40+
# id set must match the course's externals exactly, else the order would be
41+
# corrupted by a stale/partial payload.
42+
def self.reorder!(course:, ordered_ids:)
43+
scope = for_course(course)
44+
raise ArgumentError, 'ordered_ids must match the course externals' unless
45+
ordered_ids.map(&:to_i).sort == scope.pluck(:id).sort
46+
47+
transaction do
48+
ordered_ids.each_with_index do |id, index|
49+
scope.where(id: id).update_all(position: index)
50+
end
51+
end
52+
end
53+
2854
# The negative serialized id used by the synthetic tab AND the leaf assessment.
2955
def synthetic_tab_id
3056
-id
@@ -46,4 +72,10 @@ def self.create_for_course!(course:, title:, maximum_grade:, weight: 0,
4672
end
4773
end
4874
# rubocop:enable Metrics/ParameterLists
75+
76+
private
77+
78+
def assign_default_position
79+
self.position ||= self.class.next_position(course)
80+
end
4981
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+
json.id(-external_assessment.id)
3+
json.title external_assessment.title
4+
json.tabId external_assessment.synthetic_tab_id
5+
json.maxGrade external_assessment.maximum_grade.to_f
6+
json.external true
7+
json.floorAtZero external_assessment.floor_at_zero
8+
json.capAtMaximum external_assessment.cap_at_maximum
9+
json.gradebookExcluded false
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# frozen_string_literal: true
2+
json.assessment do
3+
json.partial! 'external_assessment', external_assessment: @external_assessment
4+
json.gradebookWeight(@external_assessment.gradebook_contribution&.weight&.to_f || 0) if @weighted_view_enabled
5+
end
6+
json.tab do
7+
json.id @external_assessment.synthetic_tab_id
8+
json.title @external_assessment.title
9+
json.categoryId Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID
10+
if @weighted_view_enabled
11+
json.gradebookWeight(@external_assessment.gradebook_contribution&.weight&.to_f || 0)
12+
json.weightMode 'equal'
13+
end
14+
end
15+
json.category do
16+
json.id Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID
17+
json.title Course::ExternalAssessment::SYNTHETIC_CATEGORY_TITLE
18+
end
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# frozen_string_literal: true
2+
json.assessment do
3+
json.partial! 'external_assessment', external_assessment: @external_assessment
4+
json.gradebookWeight(@external_assessment.gradebook_contribution&.weight&.to_f || 0) if @weighted_view_enabled
5+
end
6+
json.tab do
7+
json.id @external_assessment.synthetic_tab_id
8+
json.title @external_assessment.title
9+
json.categoryId Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID
10+
if @weighted_view_enabled
11+
json.gradebookWeight(@external_assessment.gradebook_contribution&.weight&.to_f || 0)
12+
json.weightMode 'equal'
13+
end
14+
end

client/app/api/course/Gradebook.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import {
2+
ExternalAssessmentNode,
3+
ExternalAssessmentUpdate,
24
ExternalGradePayload,
35
GradebookData,
46
UpdateWeightsPayload,
@@ -23,6 +25,44 @@ export default class GradebookAPI extends BaseCourseAPI {
2325
return this.client.patch(`${this.#urlPrefix}/weights`, payload);
2426
}
2527

28+
createExternal(payload: {
29+
title: string;
30+
maximumGrade: number;
31+
floorAtZero: boolean;
32+
capAtMaximum: boolean;
33+
weight?: number;
34+
}): APIResponse<ExternalAssessmentNode> {
35+
return this.client.post(`${this.#urlPrefix}/external_assessments`, payload);
36+
}
37+
38+
// `id` is the positive external id (negate the negative serialized id before calling).
39+
updateExternal(
40+
id: number,
41+
payload: {
42+
title?: string;
43+
maximumGrade?: number;
44+
floorAtZero?: boolean;
45+
capAtMaximum?: boolean;
46+
weight?: number;
47+
},
48+
): APIResponse<ExternalAssessmentUpdate> {
49+
return this.client.patch(
50+
`${this.#urlPrefix}/external_assessments/${id}`,
51+
payload,
52+
);
53+
}
54+
55+
deleteExternal(id: number): APIResponse<void> {
56+
return this.client.delete(`${this.#urlPrefix}/external_assessments/${id}`);
57+
}
58+
59+
reorderExternals(payload: { orderedIds: number[] }): APIResponse<void> {
60+
return this.client.put(
61+
`${this.#urlPrefix}/external_assessments/reorder`,
62+
payload,
63+
);
64+
}
65+
2666
setExternalGrade(
2767
id: number,
2868
payload: { studentId: number; grade: number | null },

0 commit comments

Comments
 (0)