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
19 changes: 19 additions & 0 deletions app/controllers/components/course/gradebook_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ def self.display_name
end

def sidebar_items
main_sidebar_items + settings_sidebar_items
end

private

def main_sidebar_items
return [] unless can?(:read_gradebook, current_course)

[
Expand All @@ -19,4 +25,17 @@ def sidebar_items
}
]
end

def settings_sidebar_items
return [] unless can?(:manage_gradebook_settings, current_course)

[
{
key: self.class.key,
type: :settings,
weight: 14,
path: course_admin_gradebook_path(current_course)
}
]
end
end
28 changes: 28 additions & 0 deletions app/controllers/course/admin/gradebook_settings_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true
class Course::Admin::GradebookSettingsController < Course::Admin::Controller
def edit
respond_to(&:json)
end

def update
if @settings.update(gradebook_settings_params) && current_course.save
render 'edit'
else
render json: { errors: @settings.errors }, status: :bad_request
end
end

private

def gradebook_settings_params
params.require(:settings_gradebook_component).permit(:weighted_view_enabled)
end

def component
current_component_host[:course_gradebook_component]
end

def authorize_admin
authorize! :manage_gradebook_settings, current_course
end
end
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
60 changes: 60 additions & 0 deletions app/controllers/course/gradebook_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,76 @@ class Course::GradebookController < Course::ComponentController
def index
respond_to do |format|
format.json do
@weighted_view_enabled = @settings.weighted_view_enabled
@published_assessments = fetch_published_assessments
@categories, @tabs = fetch_categories_and_tabs
@students = fetch_students
assessment_ids = @published_assessments.pluck(:id)
load_weighted_view_contributions(assessment_ids) if @weighted_view_enabled
@assessment_max_grades = Course::Assessment.max_grades(assessment_ids)
@submissions = Course::Assessment::Submission.grade_summary(
student_ids: @students.map(&:user_id),
assessment_ids: assessment_ids
)
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) }
Course::Gradebook::Contribution.bulk_update(course: current_course, updates: updates)
render json: { weights: serialize_weight_updates(updates) }
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound => e
render json: { errors: { base: e.message } }, status: :unprocessable_entity
end

private

def authorize_read_gradebook!
authorize! :read_gradebook, current_course
end

def load_weighted_view_contributions(assessment_ids)
@tab_contributions = Course::Gradebook::Contribution.
where(tab_id: @tabs.map(&:id)).index_by(&:tab_id)
@assessment_contributions = Course::Gradebook::AssessmentContribution.
where(assessment_id: assessment_ids).index_by(&:assessment_id)
end

def parse_weight_entry(entry)
{
tab_id: entry[:tabId].to_i,
weight: entry[:weight].to_f,
weight_mode: entry[:weightMode] || 'equal',
excluded_assessment_ids: (entry[:excludedAssessmentIds] || []).map(&:to_i),
assessment_weights: (entry[:assessmentWeights] || []).map do |aw|
{ assessment_id: aw[:assessmentId].to_i, weight: aw[:weight].to_f }
end
}
end

def update_weights_params
params.permit(
weights: [:tabId, :weight, :weightMode,
excludedAssessmentIds: [], assessmentWeights: [:assessmentId, :weight]]
)
end

def serialize_weight_updates(updates)
updates.map do |u|
entry = { tabId: u[:tab_id], weight: u[:weight], weightMode: u[:weight_mode].to_s,
excludedAssessmentIds: u[:excluded_assessment_ids] }
if u[:weight_mode].to_s == 'custom'
entry[:assessmentWeights] = u[:assessment_weights].map do |aw|
{ assessmentId: aw[:assessment_id], weight: aw[:weight] }
end
end
entry
end
end

def component
current_component_host[:course_gradebook_component]
end
Expand All @@ -34,6 +85,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: 3 additions & 1 deletion app/models/components/course/gradebook_ability_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ module Course::GradebookAbilityComponent
include AbilityHost::Component

def define_permissions
can :read_gradebook, Course, id: course.id if course_user&.staff?
can :read_gradebook, Course, id: course.id if course_user&.manager_or_owner?
can :manage_gradebook_weights, Course, id: course.id if course_user&.manager_or_owner?
can :manage_gradebook_settings, Course, id: course.id if course_user&.manager_or_owner?
super
end
end
4 changes: 4 additions & 0 deletions app/models/course.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ class Course < ApplicationRecord
dependent: :destroy, inverse_of: :course
has_many :assessment_tabs, source: :tabs, through: :assessment_categories
has_many :assessments, through: :assessment_categories
has_many :gradebook_contributions, class_name: 'Course::Gradebook::Contribution',
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
3 changes: 3 additions & 0 deletions app/models/course/assessment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ class Course::Assessment < ApplicationRecord
inverse_of: :assessment, dependent: :destroy
has_one :plagiarism_check, class_name: 'Course::Assessment::PlagiarismCheck',
inverse_of: :assessment, dependent: :destroy, autosave: true
has_one :gradebook_assessment_contribution,
class_name: 'Course::Gradebook::AssessmentContribution',
dependent: :destroy, inverse_of: :assessment
has_many :live_feedbacks, class_name: 'Course::Assessment::LiveFeedback',
inverse_of: :assessment, dependent: :destroy
has_many :links, class_name: 'Course::Assessment::Link', inverse_of: :assessment, dependent: :destroy
Expand Down
3 changes: 3 additions & 0 deletions app/models/course/assessment/tab.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ class Course::Assessment::Tab < ApplicationRecord

belongs_to :category, class_name: 'Course::Assessment::Category', inverse_of: :tabs
has_many :assessments, class_name: 'Course::Assessment', dependent: :destroy, inverse_of: :tab
has_one :gradebook_contribution, class_name: 'Course::Gradebook::Contribution',
dependent: :destroy, inverse_of: :tab

has_many :folders, class_name: 'Course::Material::Folder', through: :assessments,
inverse_of: nil

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::Contribution',
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::Contribution.create!(course: course, external_assessment: external,
weight: weight, weight_mode: 'equal', keep_highest: 0)
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
6 changes: 6 additions & 0 deletions app/models/course/gradebook.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true
module Course::Gradebook
def self.table_name_prefix
"#{Course.table_name.singularize}_gradebook_"
end
end
11 changes: 11 additions & 0 deletions app/models/course/gradebook/assessment_contribution.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class Course::Gradebook::AssessmentContribution < ApplicationRecord
belongs_to :assessment, class_name: 'Course::Assessment',
inverse_of: :gradebook_assessment_contribution

validates :creator, presence: true
validates :updater, presence: true
validates :weight, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
validates :excluded, inclusion: { in: [true, false] }
validates :assessment_id, uniqueness: true
end
Loading