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
1 change: 1 addition & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ commands:
command: yarn build:test
environment:
AVAILABLE_CPUS: 4
NODE_OPTIONS: --max-old-space-size=4096

- save_cache:
paths:
Expand Down
41 changes: 41 additions & 0 deletions app/controllers/components/course/gradebook_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true
class Course::GradebookComponent < SimpleDelegator
include Course::ControllerComponentHost::Component

def self.display_name
'Gradebook'
end

def sidebar_items
main_sidebar_items + settings_sidebar_items
end

private

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

[
{
key: self.class.key,
icon: :gradebook,
type: :normal,
weight: 9,
path: course_gradebook_path(current_course)
}
]
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
108 changes: 108 additions & 0 deletions app/controllers/course/gradebook_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# frozen_string_literal: true
class Course::GradebookController < Course::ComponentController
before_action :authorize_read_gradebook!
before_action :preload_levels, only: [:index]

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
)
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::TabContribution.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::TabContribution.
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

# Weights are stored as DECIMAL(5,2); round at the boundary so the echoed response
# matches the persisted value and the custom-weight sum check stays exact at 2dp.
def parse_weight_entry(entry)
{
tab_id: entry[:tabId].to_i,
weight: entry[:weight].to_f.round(2),
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.round(2) }
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

def fetch_categories_and_tabs
tabs = @published_assessments.map(&:tab).uniq(&:id)
[tabs.map(&:category).uniq(&:id), tabs]
end

def fetch_students
current_course.course_users.students.without_phantom_users.
calculated(:experience_points).includes(user: :emails).to_a
end
Comment thread
Copilot marked this conversation as resolved.

def fetch_published_assessments
current_course.assessments.
published.
includes(tab: :category).
joins(tab: :category).
reorder('course_assessment_categories.weight, course_assessment_tabs.weight, course_assessments.id')
end

# Pre-loads course levels to avoid N+1 queries when each course_user.level_number is rendered.
# level_number is derived, not an association (it buckets EXP against course.levels), so it
# can't be added to fetch_students' includes. See Course::LevelsConcern#level_for.
def preload_levels
current_course.levels.to_a
end
end
7 changes: 0 additions & 7 deletions app/controllers/course/statistics/aggregate_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,6 @@ def activity_get_help
@course_user_hash = current_course.course_users.index_by(&:user_id)
end

def download_score_summary
job = Course::Statistics::AssessmentsScoreSummaryDownloadJob.
perform_later(current_course, params[:assessment_ids]).job

render partial: 'jobs/submitted', locals: { job: job }
end

private

def sanitize_date_range(start_at_param, end_at_param)
Expand Down

This file was deleted.

11 changes: 11 additions & 0 deletions app/models/components/course/gradebook_ability_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true
module Course::GradebookAbilityComponent
include AbilityHost::Component

def define_permissions
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
10 changes: 7 additions & 3 deletions app/models/course.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true
class Course < ApplicationRecord
class Course < ApplicationRecord # rubocop:disable Metrics/ClassLength
include Course::SearchConcern
include Course::DuplicationConcern
include Course::CourseComponentsConcern
Expand Down Expand Up @@ -53,6 +53,8 @@ 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::TabContribution',
dependent: :destroy, inverse_of: :course
has_many :assessment_skills, class_name: 'Course::Assessment::Skill',
dependent: :destroy
has_many :assessment_skill_branches, class_name: 'Course::Assessment::SkillBranch',
Expand Down Expand Up @@ -362,12 +364,14 @@ def nearest_forum_discussions(query_embedding, limit: 3)
# Set default values
def set_defaults
self.start_at ||= Time.zone.now.beginning_of_hour
self.end_at ||= self.start_at + 1.month
self.end_at ||= start_at + 1.month
self.default_reference_timeline ||= reference_timelines.new(default: true)
self.default_timeline_algorithm ||= 0 # 'fixed' algorithm

return unless creator && course_users.empty?
build_owner_course_user if creator && course_users.empty?
end

def build_owner_course_user
course_users.build(user: creator,
role: :owner,
creator: creator,
Expand Down
19 changes: 19 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 Expand Up @@ -160,6 +163,22 @@ def self.use_relative_model_naming?
true
end

# Returns a hash of assessment_id => max_grade (sum of question maximum_grades).
def self.max_grades(assessment_ids)
return {} if assessment_ids.empty?

rows = find_by_sql(
sanitize_sql_array([<<-SQL.squish, assessment_ids])
SELECT cqa.assessment_id, COALESCE(SUM(caq.maximum_grade), 0) AS max_grade
FROM course_question_assessments cqa
JOIN course_assessment_questions caq ON caq.id = cqa.question_id
WHERE cqa.assessment_id IN (?)
GROUP BY cqa.assessment_id
SQL
)
rows.to_h { |row| [row.assessment_id, row.max_grade.to_f] }
end

def to_partial_path
'course/assessment/assessments/assessment'
end
Expand Down
21 changes: 21 additions & 0 deletions app/models/course/assessment/submission.rb
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,27 @@ def self.on_dependent_status_change(answer)
answer.submission.last_graded_time = Time.now
end

# Returns an array of submission rows for the given students and assessments.
# Each row has: student_id (creator_id), assessment_id, grade (float).
# Only graded/published submissions are included.
def self.grade_summary(student_ids:, assessment_ids:)
return [] if student_ids.empty? || assessment_ids.empty?

find_by_sql(
sanitize_sql_array([<<-SQL.squish, student_ids, assessment_ids])
SELECT cas.creator_id AS student_id, cas.assessment_id,
cas.id AS submission_id, SUM(caa.grade) AS grade
FROM course_assessment_submissions cas
JOIN course_assessment_answers caa ON caa.submission_id = cas.id
WHERE cas.creator_id IN (?)
AND cas.assessment_id IN (?)
AND cas.workflow_state IN ('graded', 'published')
AND caa.current_answer = TRUE
GROUP BY cas.creator_id, cas.assessment_id, cas.id
SQL
)
end

private

# Queues the submission for auto grading, after the submission has changed to the submitted state.
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::TabContribution',
dependent: :destroy, inverse_of: :tab

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

Expand Down
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