Skip to content
Draft
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
23 changes: 23 additions & 0 deletions app/controllers/components/course/gradebook_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true
class Course::GradebookComponent < SimpleDelegator
include Course::ControllerComponentHost::Component

def self.display_name
'Gradebook'
end

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

[
{
key: self.class.key,
icon: :gradebook,
title: I18n.t('course.gradebook.component.sidebar_title'),
type: :admin,
weight: 4,
path: course_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
65 changes: 65 additions & 0 deletions app/controllers/course/gradebook_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# frozen_string_literal: true
class Course::GradebookController < Course::ComponentController
before_action :authorize_read_gradebook!

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)
@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 do |entry|
{ tab_id: entry[:tabId].to_i, weight: entry[:weight].to_i }
end
Course::Assessment::Tab.update_gradebook_weights(course: current_course, updates: updates)
render json: { weights: updates.map { |u| { tabId: u[:tab_id], weight: u[:weight] } } }
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 update_weights_params
params.permit(weights: [:tabId, :weight])
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.levels.to_a
current_course.course_users.students.without_phantom_users.
calculated(:experience_points).includes(:user).to_a
end

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
end
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&.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?
super
end
end
16 changes: 16 additions & 0 deletions app/models/course/assessment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,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,
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
SQL
)
end

private

# Queues the submission for auto grading, after the submission has changed to the submitted state.
Expand Down
29 changes: 29 additions & 0 deletions app/models/course/assessment/tab.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
class Course::Assessment::Tab < ApplicationRecord
validates :title, length: { maximum: 255 }, presence: true
validates :weight, numericality: { only_integer: true }, presence: true
validates :gradebook_weight,
numericality: { only_integer: true,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 100 },
presence: true
validates :creator, presence: true
validates :updater, presence: true
validates :category, presence: true
Expand All @@ -24,6 +29,30 @@ class Course::Assessment::Tab < ApplicationRecord
select('(array_agg(title))[0:3]')
end)

# Bulk-updates the gradebook_weight for a set of tabs belonging to the given course.
# Raises ActiveRecord::RecordNotFound if any tab_id does not belong to the course.
# Raises ActiveRecord::RecordInvalid if any weight fails validation; the transaction is rolled back.
#
# @param course [Course]
# @param updates [Array<Hash>] array of { tab_id: Integer, weight: Integer }
def self.update_gradebook_weights(course:, updates:)
course_tab_ids = course.assessment_tabs.pluck(:id).to_set
tab_ids_to_update = updates.map { |e| e[:tab_id] }

tab_ids_to_update.each do |tab_id|
raise ActiveRecord::RecordNotFound unless course_tab_ids.include?(tab_id)
end

tabs_by_id = where(id: tab_ids_to_update).index_by(&:id)

transaction do
updates.each do |entry|
tab = tabs_by_id[entry[:tab_id]]
tab.update!(gradebook_weight: entry[:weight])
end
end
end

# Returns a boolean value indicating if there are other tabs
# besides this one remaining in its category.
#
Expand Down
16 changes: 16 additions & 0 deletions app/models/course/settings/gradebook_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class Course::Settings::GradebookComponent < Course::Settings::Component
# Returns whether weighted view is enabled (disabled by default).
#
# @return [Boolean] Setting on whether weighted view is enabled.
def weighted_view_enabled
ActiveRecord::Type::Boolean.new.cast(settings.weighted_view_enabled) || false
end

# Enable or disable the weighted view.
#
# @param [Boolean|Integer|String] value Setting on whether weighted view is enabled.
def weighted_view_enabled=(value)
settings.weighted_view_enabled = ActiveRecord::Type::Boolean.new.cast(value)
end
end
2 changes: 2 additions & 0 deletions app/views/course/admin/gradebook_settings/edit.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# frozen_string_literal: true
json.weightedViewEnabled @settings.weighted_view_enabled
38 changes: 38 additions & 0 deletions app/views/course/gradebook/index.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# frozen_string_literal: true
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
end

json.tabs @tabs do |tab|
json.id tab.id
json.title tab.title
json.categoryId tab.category_id
json.gradebookWeight tab.gradebook_weight if @weighted_view_enabled
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
end

json.students @students do |course_user|
json.id course_user.user_id
json.name course_user.name
json.email course_user.user.email
json.level course_user.level_number
json.totalXp course_user.experience_points
end

json.submissions @submissions do |sub|
json.studentId sub.student_id
json.assessmentId sub.assessment_id
json.grade sub.grade&.to_f
end

json.gamificationEnabled current_course.gamified?
23 changes: 23 additions & 0 deletions client/app/api/course/Admin/Gradebook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { AxiosResponse } from 'axios';
import {
GradebookSettingsData,
GradebookSettingsPostData,
} from 'types/course/admin/gradebook';

import BaseAdminAPI from './Base';

export default class GradebookAdminAPI extends BaseAdminAPI {
override get urlPrefix(): string {
return `${super.urlPrefix}/gradebook`;
}

index(): Promise<AxiosResponse<GradebookSettingsData>> {
return this.client.get(this.urlPrefix);
}

update(
data: GradebookSettingsPostData,
): Promise<AxiosResponse<GradebookSettingsData>> {
return this.client.patch(this.urlPrefix, data);
}
}
2 changes: 2 additions & 0 deletions client/app/api/course/Admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import CommentsAdminAPI from './Comments';
import ComponentsAdminAPI from './Components';
import CourseAdminAPI from './Course';
import ForumsAdminAPI from './Forums';
import GradebookAdminAPI from './Gradebook';
import LeaderboardAdminAPI from './Leaderboard';
import LessonPlanSettingsAPI from './LessonPlan';
import MaterialsAdminAPI from './Materials';
Expand All @@ -28,6 +29,7 @@ const AdminAPI = {
lessonPlan: new LessonPlanSettingsAPI(),
materials: new MaterialsAdminAPI(),
forums: new ForumsAdminAPI(),
gradebook: new GradebookAdminAPI(),
videos: new VideosAdminAPI(),
notifications: new NotificationsSettingsAPI(),
codaveri: new CodaveriAdminAPI(),
Expand Down
15 changes: 15 additions & 0 deletions client/app/api/course/Gradebook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { GradebookData } from 'types/course/gradebook';

import { APIResponse } from 'api/types';

import BaseCourseAPI from './Base';

export default class GradebookAPI extends BaseCourseAPI {
get #urlPrefix(): string {
return `/courses/${this.courseId}/gradebook`;
}

index(): APIResponse<GradebookData> {
return this.client.get(this.#urlPrefix);
}
}
2 changes: 2 additions & 0 deletions client/app/api/course/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import DuplicationAPI from './Duplication';
import EnrolRequestsAPI from './EnrolRequests';
import ExperiencePointsRecordAPI from './ExperiencePointsRecord';
import ForumAPI from './Forum';
import GradebookAPI from './Gradebook';
import GroupsAPI from './Groups';
import LeaderboardAPI from './Leaderboard';
import LearningMapAPI from './LearningMap';
Expand Down Expand Up @@ -48,6 +49,7 @@ const CourseAPI = {
experiencePointsRecord: new ExperiencePointsRecordAPI(),
folders: new FoldersAPI(),
forum: ForumAPI,
gradebook: new GradebookAPI(),
groups: new GroupsAPI(),
leaderboard: new LeaderboardAPI(),
learningMap: new LearningMapAPI(),
Expand Down
Loading