Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 77 additions & 1 deletion app/controllers/course/external_assessments_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,46 @@
# frozen_string_literal: true
class Course::ExternalAssessmentsController < Course::ComponentController
before_action :load_external_assessment, only: [:grades]
before_action :load_external_assessment, only: [:update, :destroy, :grades]

def create
authorize! :manage_gradebook_weights, current_course
@weighted_view_enabled = gradebook_settings.weighted_view_enabled
@external_assessment = Course::ExternalAssessment.create_for_course!(
course: current_course,
title: create_params[:title],
maximum_grade: create_params[:maximumGrade],
weight: create_weight,
floor_at_zero: bound_flag(:floorAtZero, default: true),
cap_at_maximum: bound_flag(:capAtMaximum, default: true)
)
render 'create'
rescue ActiveRecord::RecordInvalid => e
render json: { errors: { base: e.message } }, status: :unprocessable_entity
end

def update
authorize! :manage_gradebook_weights, current_course
@weighted_view_enabled = gradebook_settings.weighted_view_enabled
@external_assessment.update!(update_params_attrs)
update_weight if @weighted_view_enabled && params.key?(:weight)
render 'update'
rescue ActiveRecord::RecordInvalid => e
render json: { errors: { base: e.message } }, status: :unprocessable_entity
end

def destroy
authorize! :manage_gradebook_weights, current_course
@external_assessment.destroy!
head :ok
end

def reorder
authorize! :manage_gradebook_weights, current_course
Course::ExternalAssessment.reorder!(course: current_course, ordered_ids: reorder_params)
head :ok
rescue ArgumentError
head :unprocessable_entity
end

def grades
authorize! :manage_gradebook_weights, current_course
Expand All @@ -26,12 +66,44 @@ def component
current_component_host[:course_gradebook_component]
end

def gradebook_settings
@gradebook_settings ||= Course::Settings::GradebookComponent.new(component)
end

def load_external_assessment
@external_assessment = Course::ExternalAssessment.for_course(current_course).find(params[:id])
rescue ActiveRecord::RecordNotFound
head :not_found
end

def create_params
params.permit(:title, :maximumGrade, :weight, :floorAtZero, :capAtMaximum)
end

def create_weight
@weighted_view_enabled ? (create_params[:weight].presence || 0).to_f : 0
end

def update_weight
@external_assessment.gradebook_contribution&.update!(weight: (params[:weight].presence || 0).to_f)
end

def update_params_attrs
attrs = {}
attrs[:title] = params[:title] if params.key?(:title)
attrs[:maximum_grade] = params[:maximumGrade] if params.key?(:maximumGrade)
attrs[:floor_at_zero] = bound_flag(:floorAtZero, default: true) if params.key?(:floorAtZero)
attrs[:cap_at_maximum] = bound_flag(:capAtMaximum, default: true) if params.key?(:capAtMaximum)
attrs
end

# Coerce a string/bool HTTP param into a Ruby boolean (defaults when absent).
def bound_flag(key, default:)
return default unless params.key?(key)

ActiveRecord::Type::Boolean.new.cast(params[key])
end

def grade_params
params.permit(:studentId, :grade)
end
Expand All @@ -40,4 +112,8 @@ def grade_params
def normalized_grade(value)
value.blank? ? nil : value
end

def reorder_params
params.require(:orderedIds).map(&:to_i)
end
end
2 changes: 1 addition & 1 deletion app/controllers/course/gradebook_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ def fetch_categories_and_tabs
end

def load_externals
@external_assessments = Course::ExternalAssessment.for_course(current_course).
@external_assessments = Course::ExternalAssessment.for_course(current_course).order(:position).
includes(:gradebook_contribution, external_assessment_grades: :course_user).to_a
@external_grades = @external_assessments.flat_map(&:external_assessment_grades)
@external_contributions = @external_assessments.
Expand Down
32 changes: 32 additions & 0 deletions app/models/course/external_assessment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,37 @@ class Course::ExternalAssessment < ApplicationRecord
belongs_to :course, inverse_of: :external_assessments
has_one :gradebook_contribution, class_name: 'Course::Gradebook::ExternalContribution',
inverse_of: :external_assessment, dependent: :destroy
# delete_all (not destroy): grades carry no destroy callbacks, so destroying the
# parent would otherwise fire one SELECT+DELETE per grade (N+1). delete_all removes
# them in a single statement. Rails must issue it — the DB FK has no ON DELETE CASCADE.
has_many :external_assessment_grades, class_name: 'Course::ExternalAssessmentGrade',
inverse_of: :external_assessment, dependent: :delete_all

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

before_create :assign_default_position

# Next free position for the course (positions are 0-based, per course, not
# required to be unique). Drives append-at-end for both manual add and import.
def self.next_position(course)
(for_course(course).maximum(:position) || -1) + 1
end

# Rewrites positions to match ordered_ids (the canonical gradebook order). The
# id set must match the course's externals exactly, else the order would be
# corrupted by a stale/partial payload.
def self.reorder!(course:, ordered_ids:)
scope = for_course(course)
raise ArgumentError, 'ordered_ids must match the course externals' unless
ordered_ids.map(&:to_i).sort == scope.pluck(:id).sort

transaction do
ordered_ids.each_with_index do |id, index|
scope.where(id: id).update_all(position: index)
end
end
end

# The negative serialized id used by the synthetic tab AND the leaf assessment.
def synthetic_tab_id
-id
Expand All @@ -46,4 +72,10 @@ def self.create_for_course!(course:, title:, maximum_grade:, weight: 0,
end
end
# rubocop:enable Metrics/ParameterLists

private

def assign_default_position
self.position ||= self.class.next_position(course)
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true
json.id(-external_assessment.id)
json.title external_assessment.title
json.tabId external_assessment.synthetic_tab_id
json.maxGrade external_assessment.maximum_grade.to_f
json.external true
json.floorAtZero external_assessment.floor_at_zero
json.capAtMaximum external_assessment.cap_at_maximum
json.gradebookExcluded false
18 changes: 18 additions & 0 deletions app/views/course/external_assessments/create.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# frozen_string_literal: true
json.assessment do
json.partial! 'external_assessment', external_assessment: @external_assessment
json.gradebookWeight(@external_assessment.gradebook_contribution&.weight&.to_f || 0) if @weighted_view_enabled
end
json.tab do
json.id @external_assessment.synthetic_tab_id
json.title @external_assessment.title
json.categoryId Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID
if @weighted_view_enabled
json.gradebookWeight(@external_assessment.gradebook_contribution&.weight&.to_f || 0)
json.weightMode 'equal'
end
end
json.category do
json.id Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID
json.title Course::ExternalAssessment::SYNTHETIC_CATEGORY_TITLE
end
14 changes: 14 additions & 0 deletions app/views/course/external_assessments/update.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true
json.assessment do
json.partial! 'external_assessment', external_assessment: @external_assessment
json.gradebookWeight(@external_assessment.gradebook_contribution&.weight&.to_f || 0) if @weighted_view_enabled
end
json.tab do
json.id @external_assessment.synthetic_tab_id
json.title @external_assessment.title
json.categoryId Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID
if @weighted_view_enabled
json.gradebookWeight(@external_assessment.gradebook_contribution&.weight&.to_f || 0)
json.weightMode 'equal'
end
end
40 changes: 40 additions & 0 deletions client/app/api/course/Gradebook.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
ExternalAssessmentNode,
ExternalAssessmentUpdate,
ExternalGradePayload,
GradebookData,
UpdateWeightsPayload,
Expand All @@ -23,6 +25,44 @@ export default class GradebookAPI extends BaseCourseAPI {
return this.client.patch(`${this.#urlPrefix}/weights`, payload);
}

createExternal(payload: {
title: string;
maximumGrade: number;
floorAtZero: boolean;
capAtMaximum: boolean;
weight?: number;
}): APIResponse<ExternalAssessmentNode> {
return this.client.post(`${this.#urlPrefix}/external_assessments`, payload);
}

// `id` is the positive external id (negate the negative serialized id before calling).
updateExternal(
id: number,
payload: {
title?: string;
maximumGrade?: number;
floorAtZero?: boolean;
capAtMaximum?: boolean;
weight?: number;
},
): APIResponse<ExternalAssessmentUpdate> {
return this.client.patch(
`${this.#urlPrefix}/external_assessments/${id}`,
payload,
);
}

deleteExternal(id: number): APIResponse<void> {
return this.client.delete(`${this.#urlPrefix}/external_assessments/${id}`);
}

reorderExternals(payload: { orderedIds: number[] }): APIResponse<void> {
return this.client.put(
`${this.#urlPrefix}/external_assessments/reorder`,
payload,
);
}

setExternalGrade(
id: number,
payload: { studentId: number; grade: number | null },
Expand Down
Loading