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
43 changes: 43 additions & 0 deletions app/controllers/course/external_assessments_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true
class Course::ExternalAssessmentsController < Course::ComponentController
before_action :load_external_assessment, only: [:grades]

def grades
authorize! :manage_gradebook_weights, current_course
# The gradebook keys students by user_id (see index/update_grade jbuilders), so the
# `studentId` param is a user_id, not a course_user PK.
course_user = current_course.course_users.find_by!(user_id: grade_params[:studentId])
@grade = @external_assessment.external_assessment_grades.
find_or_initialize_by(course_user: course_user)
@grade.grade = normalized_grade(grade_params[:grade])
@grade.save!
render 'update_grade'
rescue ActiveRecord::RecordNotUnique
retry
rescue ActiveRecord::RecordNotFound
head :not_found
rescue ActiveRecord::RecordInvalid => e
render json: { errors: { base: e.message } }, status: :unprocessable_entity
end

private

def component
current_component_host[:course_gradebook_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 grade_params
params.permit(:studentId, :grade)
end

# Blank cell clears the grade to null (ungraded), never zero (decision #7).
def normalized_grade(value)
value.blank? ? nil : value
end
end
32 changes: 25 additions & 7 deletions app/controllers/course/gradebook_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,15 @@ def index
load_weighted_view(assessment_ids) if @weighted_view_enabled
load_grades(assessment_ids)
@student_level_contributions = compute_student_level_contributions
load_externals
end
end
end

def update_weights
authorize! :manage_gradebook_weights, current_course
updates = (update_weights_params[:weights] || []).map { |entry| parse_weight_entry(entry) }
level_config = nil
# One transaction so a rejected level formula rolls back the tab-weight writes too,
# rather than leaving a partial save.
ActiveRecord::Base.transaction do
Course::Gradebook::TabContribution.bulk_update(course: current_course, updates: updates)
level_config = persist_level_contribution
end
level_config = persist_weight_updates(updates)
response_body = { weights: serialize_weight_updates(updates) }
response_body[:levelContribution] = serialize_level_contribution(level_config) if level_config
render json: response_body
Expand All @@ -38,6 +33,20 @@ def update_weights

private

# Persists tab weights, external-assessment weights (negative tab ids), and the optional
# level contribution atomically, so a mixed save from the single weights request can't desync.
# Returns the persisted LevelConfig, or nil when no levelContribution was sent.
def persist_weight_updates(updates)
external_updates, tab_updates = updates.partition { |entry| entry[:tab_id] < 0 }
level_config = nil
ActiveRecord::Base.transaction do
Course::Gradebook::TabContribution.bulk_update(course: current_course, updates: tab_updates)
Course::Gradebook::ExternalContribution.bulk_update(course: current_course, updates: external_updates)
level_config = persist_level_contribution
end
level_config
end

def authorize_read_gradebook!
authorize! :read_gradebook, current_course
end
Expand Down Expand Up @@ -150,6 +159,15 @@ def fetch_categories_and_tabs
[tabs.map(&:category).uniq(&:id), tabs]
end

def load_externals
@external_assessments = Course::ExternalAssessment.for_course(current_course).
includes(:gradebook_contribution, external_assessment_grades: :course_user).to_a
@external_grades = @external_assessments.flat_map(&:external_assessment_grades)
@external_contributions = @external_assessments.
index_by(&:id).
transform_values(&:gradebook_contribution)
end

def fetch_students
current_course.course_users.students.without_phantom_users.
calculated(:experience_points).includes(user: :emails).to_a.
Expand Down
4 changes: 4 additions & 0 deletions app/models/course.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ class Course < ApplicationRecord # rubocop:disable Metrics/ClassLength
dependent: :destroy, inverse_of: :course
has_one :gradebook_level_config, class_name: 'Course::Gradebook::LevelConfig',
dependent: :destroy, inverse_of: :course
has_many :gradebook_external_contributions, class_name: 'Course::Gradebook::ExternalContribution',
dependent: :destroy, inverse_of: :course
has_many :external_assessments, class_name: 'Course::ExternalAssessment',
inverse_of: :course, dependent: :destroy
has_many :assessment_skills, class_name: 'Course::Assessment::Skill',
dependent: :destroy
has_many :assessment_skill_branches, class_name: 'Course::Assessment::SkillBranch',
Expand Down
49 changes: 49 additions & 0 deletions app/models/course/external_assessment.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# frozen_string_literal: true
# A gradebook component graded outside Coursemology (e.g. a midterm or final).
# It is a first-class gradebook contributor, NOT a Course::Assessment: it never
# touches attempts, EXP, statistics, todos, or the lesson plan. Its weight lives on
# its course_gradebook_contributions row; its display grouping is synthesised by the
# gradebook serializer (no real tab/category exists).
class Course::ExternalAssessment < ApplicationRecord
# Sentinel id for the serializer's synthetic "External Assessments" category.
# Native categories are positive; externals and their synthetic grouping are negative.
SYNTHETIC_CATEGORY_ID = -1
SYNTHETIC_CATEGORY_TITLE = 'External Assessments'

validates :title, length: { maximum: 255 }, presence: true
validates :title, uniqueness: { scope: :course_id }
validates :maximum_grade, presence: true
validates :maximum_grade, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
validates :creator, presence: true
validates :updater, presence: true

belongs_to :course, inverse_of: :external_assessments
has_one :gradebook_contribution, class_name: 'Course::Gradebook::ExternalContribution',
inverse_of: :external_assessment, dependent: :destroy
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) }

# The negative serialized id used by the synthetic tab AND the leaf assessment.
def synthetic_tab_id
-id
end

# Creates an external assessment and its gradebook contribution in one transaction.
# Raises ActiveRecord::RecordInvalid on a duplicate title within the course.
# rubocop:disable Metrics/ParameterLists -- factory mirrors the model's columns; named kwargs are clearer than a struct
def self.create_for_course!(course:, title:, maximum_grade:, weight: 0,
floor_at_zero: true, cap_at_maximum: true)
transaction do
external = course.external_assessments.create!(
title: title, maximum_grade: maximum_grade,
floor_at_zero: floor_at_zero, cap_at_maximum: cap_at_maximum
)
Course::Gradebook::ExternalContribution.create!(course: course, external_assessment: external,
weight: weight)
external
end
end
# rubocop:enable Metrics/ParameterLists
end
16 changes: 16 additions & 0 deletions app/models/course/external_assessment_grade.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true
# One external grade for a (external assessment, course_user). The binding key is
# course_user_id (the authoritative link to the person); imported_identifier is a
# non-authoritative snapshot of the email/Student ID used at import (audit + upsert
# mismatch detection), null for grades typed/edited inline.
class Course::ExternalAssessmentGrade < ApplicationRecord
validates :course_user, presence: true
validates :grade, numericality: true, allow_nil: true
validates :course_user_id, uniqueness: { scope: :external_assessment_id }
validates :creator, presence: true
validates :updater, presence: true

belongs_to :external_assessment, class_name: 'Course::ExternalAssessment',
inverse_of: :external_assessment_grades
belongs_to :course_user, inverse_of: :external_assessment_grades
end
46 changes: 46 additions & 0 deletions app/models/course/gradebook/external_contribution.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# frozen_string_literal: true
class Course::Gradebook::ExternalContribution < ApplicationRecord
belongs_to :course, inverse_of: :gradebook_external_contributions
belongs_to :external_assessment, class_name: 'Course::ExternalAssessment',
inverse_of: :gradebook_contribution

validates :creator, presence: true
validates :updater, presence: true
validates :weight, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
validates :external_assessment_id, uniqueness: true
validate :course_matches_external_assessment

# Upserts one external-assessment contribution per entry. Entries arrive with the gradebook's
# negative-tab_id wire encoding (tab_id == -external_assessment_id), shared with the tab payload.
# Raises ActiveRecord::RecordNotFound for an unknown/foreign external, ActiveRecord::RecordInvalid
# on an out-of-range weight; the transaction rolls back.
#
# @param course [Course]
# @param updates [Array<Hash>] each { tab_id: (negative external id), weight: }
def self.bulk_update(course:, updates:)
externals_by_id = course.external_assessments.
where(id: updates.map { |e| -e[:tab_id] }).index_by(&:id)
updates.each { |e| raise ActiveRecord::RecordNotFound unless externals_by_id.key?(-e[:tab_id]) }

transaction do
updates.each { |entry| upsert(course, externals_by_id[-entry[:tab_id]], entry[:weight]) }
end
end

# Upserts a single contribution for the given external assessment.
def self.upsert(course, external, weight)
contribution = find_or_initialize_by(external_assessment_id: external.id)
contribution.course = course
contribution.weight = weight
contribution.save!
end
private_class_method :upsert

private

def course_matches_external_assessment
return if external_assessment.nil? || course.nil?

errors.add(:course, :invalid) if external_assessment.course_id != course_id
end
end
2 changes: 2 additions & 0 deletions app/models/course_user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ class CourseUser < ApplicationRecord
inverse_of: :course_user, dependent: :destroy
has_many :groups, through: :group_users, class_name: 'Course::Group', source: :group
has_many :personal_times, class_name: 'Course::PersonalTime', inverse_of: :course_user, dependent: :destroy
has_many :external_assessment_grades, class_name: 'Course::ExternalAssessmentGrade',
inverse_of: :course_user, dependent: :destroy
belongs_to :reference_timeline, class_name: 'Course::ReferenceTimeline', inverse_of: :course_users, optional: true

default_scope { where(deleted_at: nil) }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# frozen_string_literal: true
json.studentId @grade.course_user.user_id
json.assessmentId(-@grade.external_assessment_id)
json.grade @grade.grade&.to_f
103 changes: 77 additions & 26 deletions app/views/course/gradebook/index.json.jbuilder
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,72 @@
json.weightedViewEnabled @weighted_view_enabled
json.canManageWeights can?(:manage_gradebook_weights, current_course)

json.categories @categories do |cat|
json.id cat.id
json.title cat.title
json.categories do
json.array!(@categories) do |cat|
json.id cat.id
json.title cat.title
end
if @external_assessments.any?
json.child! do
json.id Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID
json.title Course::ExternalAssessment::SYNTHETIC_CATEGORY_TITLE
end
end
end

json.tabs @tabs do |tab|
json.id tab.id
json.title tab.title
json.categoryId tab.category_id
if @weighted_view_enabled
contribution = @tab_contributions[tab.id]
json.gradebookWeight (contribution&.weight || 0).to_f
json.weightMode(contribution&.weight_mode || 'equal')
json.keepHighest(contribution&.keep_highest || 0)
json.tabs do
json.array!(@tabs) do |tab|
json.id tab.id
json.title tab.title
json.categoryId tab.category_id
if @weighted_view_enabled
contribution = @tab_contributions[tab.id]
json.gradebookWeight (contribution&.weight || 0).to_f
json.weightMode(contribution&.weight_mode || 'equal')
json.keepHighest(contribution&.keep_highest || 0)
end
end
@external_assessments.each do |external|
json.child! do
json.id external.synthetic_tab_id
json.title external.title
json.categoryId Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID
if @weighted_view_enabled
contribution = @external_contributions[external.id]
json.gradebookWeight (contribution&.weight || 0).to_f
json.weightMode 'equal'
end
end
end
end

json.assessments @published_assessments do |assessment|
json.id assessment.id
json.title assessment.title
json.tabId assessment.tab_id
json.maxGrade @assessment_max_grades[assessment.id] || 0
if @weighted_view_enabled
contribution = @assessment_contributions[assessment.id]
json.gradebookWeight contribution&.weight&.to_f
json.gradebookExcluded(contribution&.excluded || false)
json.assessments do
json.array!(@published_assessments) do |assessment|
json.id assessment.id
json.title assessment.title
json.tabId assessment.tab_id
json.maxGrade @assessment_max_grades[assessment.id] || 0
if @weighted_view_enabled
contribution = @assessment_contributions[assessment.id]
json.gradebookWeight contribution&.weight&.to_f
json.gradebookExcluded(contribution&.excluded || false)
end
end
@external_assessments.each do |external|
json.child! do
json.id(-external.id)
json.title external.title
json.tabId external.synthetic_tab_id
json.maxGrade external.maximum_grade.to_f
json.external true
json.floorAtZero external.floor_at_zero
json.capAtMaximum external.cap_at_maximum
if @weighted_view_enabled
contribution = @external_contributions[external.id]
json.gradebookWeight contribution&.weight&.to_f
json.gradebookExcluded false
end
end
end
end

Expand All @@ -41,11 +81,20 @@ json.students @students do |course_user|
json.levelContribution @student_level_contributions[course_user.user_id]
end

json.submissions @submissions do |sub|
json.studentId sub.student_id
json.assessmentId sub.assessment_id
json.submissionId sub.submission_id
json.grade sub.grade&.to_f
json.submissions do
json.array!(@submissions) do |sub|
json.submissionId sub.submission_id
json.studentId sub.student_id
json.assessmentId sub.assessment_id
json.grade sub.grade&.to_f
end
@external_grades.each do |grade|
json.child! do
json.studentId grade.course_user.user_id
json.assessmentId(-grade.external_assessment_id)
json.grade grade.grade&.to_f
end
end
end

json.gamificationEnabled current_course.gamified?
Expand All @@ -67,3 +116,5 @@ json.levelContribution do
json.clamp true
end
end

json.userId current_user&.id
16 changes: 15 additions & 1 deletion client/app/api/course/Gradebook.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { GradebookData, UpdateWeightsPayload } from 'types/course/gradebook';
import {
ExternalGradePayload,
GradebookData,
UpdateWeightsPayload,
} from 'types/course/gradebook';

import { APIResponse } from 'api/types';

Expand All @@ -18,4 +22,14 @@ export default class GradebookAPI extends BaseCourseAPI {
): APIResponse<UpdateWeightsPayload> {
return this.client.patch(`${this.#urlPrefix}/weights`, payload);
}

setExternalGrade(
id: number,
payload: { studentId: number; grade: number | null },
): APIResponse<ExternalGradePayload> {
return this.client.put(
`${this.#urlPrefix}/external_assessments/${id}/grades`,
payload,
);
}
}
Loading