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
45 changes: 45 additions & 0 deletions app/controllers/course/external_assessment_imports_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# frozen_string_literal: true
class Course::ExternalAssessmentImportsController < Course::ComponentController
Service = Course::Gradebook::ExternalAssessmentImportService

def preview
authorize! :manage_gradebook_weights, current_course
@result = build_service.preview
render 'preview'
rescue Service::ImportError => e
render json: { errors: e.payload }, status: :unprocessable_entity
end

def create
authorize! :manage_gradebook_weights, current_course
@summary = build_service.commit(on_conflict: params[:onConflict])
render 'create'
rescue Service::ImportError => e
render json: { errors: e.payload }, status: :unprocessable_entity
end

private

def component
current_component_host[:course_gradebook_component]
end

def build_service
Service.new(
course: current_course,
actor: current_user,
components: import_params[:components].map do |c|
{ name: c[:name], weightage: c[:weightage].to_i, maximum_grade: c[:maximumGrade].to_f }
end,
identifier_mode: import_params[:identifierMode],
csv_data: import_params[:csvData]
)
end

def import_params
params.permit(
:identifierMode, :csvData, :onConflict,
components: [:name, :weightage, :maximumGrade]
)
end
end
78 changes: 78 additions & 0 deletions app/controllers/course/external_assessments_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# frozen_string_literal: true
class Course::ExternalAssessmentsController < Course::ComponentController
before_action :load_external_assessment, only: [:update, :destroy, :grades]

def create
authorize! :manage_gradebook_weights, current_course
@external_assessment = Course::ExternalAssessment.create_for_course!(
course: current_course,
title: create_params[:title],
maximum_grade: create_params[:maximumGrade]
)
render 'create'
rescue ActiveRecord::RecordInvalid => e
render json: { errors: { base: e.message } }, status: :unprocessable_entity
end

def update
authorize! :manage_gradebook_weights, current_course
@external_assessment.update!(update_params_attrs)
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 grades
authorize! :grade, @external_assessment
course_user = current_course.course_users.find(grade_params[:courseUserId])
@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 create_params
params.permit(:title, :maximumGrade)
end

def update_params_attrs
attrs = {}
attrs[:title] = params[:title] if params.key?(:title)
attrs[:maximum_grade] = params[:maximumGrade] if params.key?(:maximumGrade)
attrs
end

def grade_params
params.permit(:courseUserId, :grade)
end

# Blank cell clears the grade to null (ungraded), never zero (decision #7).
def normalized_grade(value)
value.blank? ? nil : value
end
end
10 changes: 10 additions & 0 deletions app/controllers/course/gradebook_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def index
student_ids: @students.map(&:user_id),
assessment_ids: assessment_ids
)
load_externals
end
end
end
Expand Down Expand Up @@ -83,6 +84,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.levels.to_a
current_course.course_users.students.without_phantom_users.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ def define_permissions
can :read_gradebook, Course, id: course.id if course_user&.staff?
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?
can :grade, Course::ExternalAssessment if course_user&.teaching_staff?
super
end
end
2 changes: 2 additions & 0 deletions app/models/course.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ class Course < ApplicationRecord
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
1 change: 0 additions & 1 deletion app/models/course/assessment/tab.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ class Course::Assessment::Tab < ApplicationRecord
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
43 changes: 43 additions & 0 deletions app/models/course/external_assessment.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# 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: :destroy

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.
def self.create_for_course!(course:, title:, maximum_grade:, weight: 0)
transaction do
external = course.external_assessments.create!(title: title, maximum_grade: maximum_grade)
Course::Gradebook::Contribution.create!(course: course, external_assessment: external,
weight: weight, weight_mode: 'equal', keep_highest: 0)
external
end
end
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
44 changes: 35 additions & 9 deletions app/models/course/gradebook/contribution.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ class Course::Gradebook::Contribution < ApplicationRecord
belongs_to :course, inverse_of: :gradebook_contributions
belongs_to :tab, class_name: 'Course::Assessment::Tab',
inverse_of: :gradebook_contribution, optional: true
belongs_to :external_assessment, class_name: 'Course::ExternalAssessment',
inverse_of: :gradebook_contribution, optional: true

validates :creator, presence: true
validates :updater, presence: true
validates :weight, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
validates :weight_mode, presence: true
validates :keep_highest, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :tab_id, uniqueness: true
validates :tab_id, uniqueness: { allow_nil: true }
validates :external_assessment_id, uniqueness: { allow_nil: true }
validate :exactly_one_contributor
validate :course_matches_contributor
# Bulk-upserts tab contributions and their per-assessment contributions for a course.
Expand All @@ -27,13 +30,22 @@ class Course::Gradebook::Contribution < ApplicationRecord
# @param updates [Array<Hash>] each { tab_id:, weight:, weight_mode:, keep_highest:,
# excluded_assessment_ids: [Integer], assessment_weights: [{ assessment_id:, weight: }] }
def self.bulk_update(course:, updates:)
external_updates, tab_updates = updates.partition { |e| e[:tab_id].to_i < 0 }

course_tab_ids = course.assessment_tabs.pluck(:id).to_set
updates.each { |e| raise ActiveRecord::RecordNotFound unless course_tab_ids.include?(e[:tab_id]) }
tab_updates.each { |e| raise ActiveRecord::RecordNotFound unless course_tab_ids.include?(e[:tab_id]) }

external_ids = external_updates.map { |e| -e[:tab_id] }
externals_by_id = course.external_assessments.where(id: external_ids).index_by(&:id)
external_updates.each { |e| raise ActiveRecord::RecordNotFound unless externals_by_id.key?(-e[:tab_id]) }

tabs_by_id = Course::Assessment::Tab.where(id: updates.map { |e| e[:tab_id] }).
tabs_by_id = Course::Assessment::Tab.where(id: tab_updates.map { |e| e[:tab_id] }).
includes(:assessments).index_by(&:id)

transaction { updates.each { |entry| apply_entry(course, tabs_by_id, entry) } }
transaction do
tab_updates.each { |entry| apply_entry(course, tabs_by_id, entry) }
external_updates.each { |entry| apply_external_entry(course, externals_by_id[-entry[:tab_id]], entry) }
end
end

# @api private
Expand All @@ -58,6 +70,16 @@ def self.apply_entry(course, tabs_by_id, entry)
end
private_class_method :apply_entry

# @api private
def self.apply_external_entry(course, external, entry)
contribution = find_or_initialize_by(external_assessment_id: external.id)
contribution.tab = nil
contribution.course = course
contribution.assign_attributes(weight: entry[:weight], weight_mode: 'equal', keep_highest: 0)
contribution.save!
end
private_class_method :apply_external_entry

# @api private
def self.assessment_contribution_for(assessment)
Course::Gradebook::AssessmentContribution.find_or_initialize_by(assessment_id: assessment.id)
Expand Down Expand Up @@ -117,15 +139,19 @@ def self.validate_custom_assessment_weights_sum!(tab, entry, included_sum, inclu

private

# Until the external-alignment design adds `external_assessment_id`, the only
# contributor is the tab, so "exactly one" reduces to "tab present".
def exactly_one_contributor
errors.add(:tab, :blank) if tab_id.blank?
return if [tab_id, external_assessment_id].compact.size == 1

errors.add(:base, :exactly_one_contributor)
end

def course_matches_contributor
return if tab.nil? || course.nil?
return if course.nil?

errors.add(:course, :invalid) if tab.category.course_id != course_id
contributor_course_id =
if tab then tab.category.course_id
elsif external_assessment then external_assessment.course_id
end
errors.add(:course, :invalid) if contributor_course_id && contributor_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
Loading