diff --git a/Gemfile b/Gemfile
index 45367d901..c4c4be3ea 100644
--- a/Gemfile
+++ b/Gemfile
@@ -49,7 +49,7 @@ gem 'rake', '>=10.3.2'
gem 'populator', '>=1.0.0'
# To communicate with MySQL database
-gem 'mysql2', '~>0.5'
+gem 'mysql2', '=0.5.4'
# Development server
gem 'thin'
diff --git a/Gemfile.lock b/Gemfile.lock
index 37120e5a4..ea382c020 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -231,7 +231,7 @@ GEM
momentjs-rails (2.15.1)
railties (>= 3.1)
multi_xml (0.6.0)
- mysql2 (0.5.5)
+ mysql2 (0.5.4)
net-http (0.4.0)
uri
net-imap (0.4.9)
@@ -470,6 +470,7 @@ PLATFORMS
arm64-darwin-21
arm64-darwin-22
arm64-darwin-23
+ arm64-darwin-24
x86_64-darwin-19
x86_64-darwin-20
x86_64-darwin-21
@@ -509,7 +510,7 @@ DEPENDENCIES
mini_racer (~> 0.6.3)
moment_timezone-rails
momentjs-rails (>= 2.9.0)
- mysql2 (~> 0.5)
+ mysql2 (= 0.5.4)
net-http
net-ldap
newrelic_rpm
diff --git a/app/assets/javascripts/annotations.js b/app/assets/javascripts/annotations.js
index 9380f2b80..6930787d6 100644
--- a/app/assets/javascripts/annotations.js
+++ b/app/assets/javascripts/annotations.js
@@ -27,13 +27,13 @@ $(window).on('resize', function () {
function getSharedCommentsForProblem(problem_id) {
return localCache['shared_comments'][problem_id]?.map(
(annotation) => {
- return {label: annotation.comment ?? annotation, value: annotation}
+ return { label: annotation.comment ?? annotation, value: annotation }
}
)
}
const selectAnnotation = box => (e, ui) => {
- const {label, value} = ui.item;
+ const { label, value } = ui.item;
const score = value.value ?? 0;
box.find('#comment-score').val(score);
@@ -44,7 +44,7 @@ const selectAnnotation = box => (e, ui) => {
return false;
}
-function focusAnnotation( event, ui ) {
+function focusAnnotation(event, ui) {
$(this).val(ui.item.label);
return false;
}
@@ -191,7 +191,7 @@ function plusFix(n) {
function fillAnnotationBox() {
retrieveSharedComments();
$('#loadScreen').css('display', 'flex');
- $.get(document.URL, function(data) {
+ $.get(document.URL, function (data) {
const $page = $('
').html(data);
$('.problemGrades').html($page.find('.problemGrades'));
$('#annotationPane').html($page.find(' #annotationPane'));
@@ -382,7 +382,7 @@ function attachEvents() {
$('.code-table').scrollTo($annotationLine.children().last(), { duration: "fast" });
refreshAnnotations();
} else {
- M.toast({html: 'Only one annotation can be created per line at a time!'});
+ M.toast({ html: 'Only one annotation can be created per line at a time!' });
}
});
}
@@ -461,7 +461,7 @@ function attachAnnotationPaneEvents() {
});
// Chevron events (collapse / show problem)
- $('.collapsible-header-controls .collapse-icon, .collapsible-header-controls .expand-icon').on('click', function(e) {
+ $('.collapsible-header-controls .collapse-icon, .collapsible-header-controls .expand-icon').on('click', function (e) {
e.preventDefault();
if ($('#loadScreen').css('display') === 'flex') return;
$(e.target).closest(".collapsible-header-wrap").find(".collapsible-header").click();
@@ -545,23 +545,31 @@ function newAnnotationFormCode() {
problemGraderId[score.problem_id] = score.grader_id;
});
+ // Clear any existing options from the problem dropdown
+ var $problemSelect = box.find('select.problem-id');
+ $problemSelect.empty();
+
+ // Add a default select option
+ $problemSelect.append($('-- Select Problem -- '));
+
+ // Add problems to the dropdown
var processStarred = false;
_.each(problems, function (problem) {
if (problemGraderId[problem.id] !== 0) { // Because grader == 0 is autograder
- if(problem.starred && !processStarred){
- box.find('select').append(
- $(' ').text('Starred Problems')
+ if (problem.starred && !processStarred) {
+ box.find('select.problem-id').append(
+ $(' ').text('Starred Problems')
);
- processStarred=true;
+ processStarred = true;
}
- if(!problem.starred && processStarred){
- box.find('select').append(
- $(' ').text('-------------------')
+ if (!problem.starred && processStarred) {
+ box.find('select.problem-id').append(
+ $(' ').text('-------------------')
);
- processStarred=false;
+ processStarred = false;
}
- box.find("select").append(
- $(" ").val(problem.id).text(problem.name)
+ box.find("select.problem-id").append(
+ $(" ").val(problem.id).text(problem.name)
);
}
});
@@ -571,13 +579,14 @@ function newAnnotationFormCode() {
e.preventDefault();
$(this).parents(".annotation-form").parent().remove();
refreshAnnotations();
- })
+ });
+ // Setup autocomplete for the comment field
box.find('#comment-textarea').autocomplete({
appendTo: box.find('#comment-textarea').parent(),
- source: getSharedCommentsForProblem(box.find("select").val()) || [],
minLength: 0,
delay: 0,
+ source: getSharedCommentsForProblem(box.find("select.problem-id").val()) || [],
select: selectAnnotation(box),
focus: focusAnnotation
}).focus(function () {
@@ -585,23 +594,73 @@ function newAnnotationFormCode() {
$(this).autocomplete('search', $(this).val())
});
- box.tooltip();
+ // Handle problem selection change - update rubric items dropdown
+ box.find("select.problem-id").on('change', function () {
+ var problem_id = $(this).val();
+ var $rubricSelect = box.find('select.rubric-item-id');
+ var $rubricContainer = $rubricSelect.closest('.row');
- box.find("select").on('change', function () {
- const problem_id = $(this).val();
+ // Clear existing options
+ $rubricSelect.empty();
- // Update autocomplete to display shared comments for selected problem
- box.find("#comment-textarea").autocomplete({
- source: getSharedCommentsForProblem(problem_id) || []
+ // If no problem selected, hide the rubric dropdown
+ if (!problem_id) {
+ $rubricContainer.hide();
+ return;
+ }
+
+ // Check if selected problem has any rubric items
+ var rubricItems = rubricItemsByProblem[problem_id] || [];
+ if (rubricItems.length === 0) {
+ // No rubric items for this problem, hide the dropdown
+ $rubricContainer.hide();
+ return;
+ }
+
+ // Problem has rubric items, show the dropdown
+ $rubricContainer.show();
+ $rubricSelect.prop('disabled', false);
+
+ // Add "No Rubric" option
+ $rubricSelect.append($('-- No Rubric Item -- '));
+
+ // Add rubric items for the selected problem
+ rubricItems.forEach(function (item) {
+ $rubricSelect.append(
+ $(' ')
+ .val(item.id)
+ .text(item.description + " (" + plusFix(item.points) + " points)")
+ );
});
+
+ // Update autocomplete for the selected problem
+ box.find('#comment-textarea').autocomplete({
+ source: getSharedCommentsForProblem(problem_id) || [],
+ });
+ });
+
+ // Initial state: hide rubric items dropdown until a problem with rubric items is selected
+ box.find('select.rubric-item-id').closest('.row').hide();
+
+ // Setup event for rubric item selection
+ box.find('.rubric-item-id').on('change', function () {
+ const selectedRubricId = $(this).val();
+ if (selectedRubricId) {
+ const problem_id = box.find('select.problem-id').val();
+ const rubricItems = rubricItemsByProblem[problem_id] || [];
+ const rubricItem = rubricItems.find(function (item) { return item.id == selectedRubricId; });
+ }
});
+ box.tooltip();
+
box.find('.annotation-form').submit(function (e) {
e.preventDefault();
var comment = $(this).find(".comment").val();
var shared_comment = $(this).find("#shared-comment").is(":checked");
var score = $(this).find(".score").val();
var problem_id = $(this).find(".problem-id").val();
+ var rubric_item_id = $(this).find(".rubric-item-id").val() || null;
var line = $(this).parent().parent().data("lineId");
if (comment === undefined || comment === "") {
@@ -622,8 +681,7 @@ function newAnnotationFormCode() {
return;
}
-
- submitNewAnnotation(comment, shared_comment, false, score, problem_id, line, $(this));
+ submitNewAnnotation(comment, shared_comment, false, score, problem_id, line, $(this), rubric_item_id);
});
return box;
@@ -723,20 +781,20 @@ function initializeBoxForm(box, annotation) {
var processStarred = false;
_.each(problems, function (problem) {
if (problemGraderId[problem.id] !== 0) { // Because grader == 0 is autograder
- if(problem.starred && !processStarred){
+ if (problem.starred && !processStarred) {
box.find('select').append(
- $(' ').text('Starred Problems')
+ $(' ').text('Starred Problems')
);
- processStarred=true;
+ processStarred = true;
}
- if(!problem.starred && processStarred){
+ if (!problem.starred && processStarred) {
box.find('select').append(
- $(' ').text('-------------------')
+ $(' ').text('-------------------')
);
- processStarred=false;
+ processStarred = false;
}
box.find("select").append(
- $(" ").val(problem.id).text(problem.name)
+ $(" ").val(problem.id).text(problem.name)
);
}
});
@@ -815,7 +873,7 @@ function newAnnotationBox(annotation) {
e.preventDefault();
box.find('.annotation-box').hide();
box.find('.annotation-form').show().css('width', '100%');
-
+
M.textareaAutoResize(box.find('#comment-textarea'));
box.find('#comment-textarea').autocomplete({
@@ -830,7 +888,7 @@ function newAnnotationBox(annotation) {
$(this).autocomplete('search', $(this).val())
});
box.tooltip();
-
+
refreshAnnotations();
});
@@ -839,7 +897,7 @@ function newAnnotationBox(annotation) {
// Update autocomplete to display shared comments for selected problem
box.find("#comment-textarea").autocomplete({
- source: getSharedCommentsForProblem(problem_id) || [],
+ source: getSharedCommentsForProblem(problem_id) || [],
});
});
@@ -1318,9 +1376,18 @@ var submitNewPDFAnnotation = function (comment, value, problem_id, pageInd, xRat
}
/* sets up and calls $.ajax to submit an annotation */
-var submitNewAnnotation = function (comment, shared_comment, global_comment, value, problem_id, lineInd, form) {
+var submitNewAnnotation = function (comment, shared_comment, global_comment, value, problem_id, lineInd, form, rubric_item_id) {
var newAnnotation = createAnnotation();
- Object.assign(newAnnotation, { line: parseInt(lineInd), comment, value, problem_id, filename: fileNameStr, shared_comment, global_comment });
+ Object.assign(newAnnotation, {
+ line: parseInt(lineInd),
+ comment,
+ value,
+ problem_id,
+ filename: fileNameStr,
+ shared_comment,
+ global_comment,
+ rubric_item_id // Add this parameter
+ });
if (comment === undefined || comment === "") {
$(form).find('.error').text("Could not save annotation. Please refresh the page and try again.").show();
diff --git a/app/assets/stylesheets/annotations.scss b/app/assets/stylesheets/annotations.scss
index 18e886124..712babe08 100755
--- a/app/assets/stylesheets/annotations.scss
+++ b/app/assets/stylesheets/annotations.scss
@@ -428,6 +428,27 @@
margin-bottom: 10px;
}
+.annotation-form select#annotation-rubric-item-id {
+ margin-top: 10px;
+ font-size: 0.9rem;
+}
+
+.annotation-form .helper-text {
+ color: #666;
+ font-size: 0.8rem;
+ margin-top: 4px;
+}
+
+.annotation-form optgroup {
+ font-weight: bold;
+ color: #333;
+}
+
+.annotation-form optgroup option {
+ font-weight: normal;
+ padding-left: 10px;
+}
+
#code-box pre {
padding-top: 0;
padding-bottom: 0;
@@ -687,6 +708,11 @@
border: none;
}
+.problem_item {
+ border-bottom: 3px solid #e5e5e5;
+ margin-bottom: 10px;
+}
+
.problem-grade-item{
background-color: #fff;
line-height: 1.5rem;
@@ -947,3 +973,95 @@ span > .material-icons {
.hljs-comment span {
color: var($autolab-highlight-comments) !important;
}
+
+.rubric-items-container {
+ margin: 10px 0;
+ padding: 0px;
+}
+.rubric-items-list {
+ list-style: none;
+ padding: 0;
+}
+.rubric-item {
+ margin-bottom: 5px;
+}
+
+.rubric-item-toggle-btn:hover {
+ background-color: #e2e2e2;
+}
+
+.rubric-item-desc {
+ flex-grow: 1;
+}
+.rubric-item-points {
+ margin: 0 10px;
+ font-weight: bold;
+}
+.rubric-toggle-form {
+ margin: 0;
+ padding: 0;
+}
+
+.rubric-item {
+ margin-bottom: 5px;
+ padding-bottom: 5px;
+}
+
+.rubric-item-toggle-btn {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ padding: 8px 12px;
+ background: #f9f9f9;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ text-align: left;
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
+
+.rubric-item-toggle-btn.active {
+ background-color: #e2e2e2;
+ border-color: #ccc;
+}
+
+.rubric-item-desc {
+ flex: 1;
+ margin-right: 10px;
+}
+
+.rubric-item-points {
+ margin-right: 10px;
+ font-weight: bold;
+}
+
+.rubric-toggle-icon {
+ font-size: 18px;
+}
+
+.rubric-item-annotations {
+ margin-left: 20px;
+ padding: 5px 0px 0px 10px;
+ border-left: 2px solid #ddd;
+}
+
+.rubric-item-annotations .annotation-badge {
+ display: flex;
+ align-items: center;
+ margin-bottom: 10px;
+}
+
+.rubric-item-badge {
+ display: inline-flex;
+ align-items: center;
+ background-color: #f0f0f0;
+ border-radius: 4px;
+ padding: 2px 5px;
+ margin-left: 5px;
+ font-size: 0.8em;
+}
+
+.rubric-item-badge i {
+ margin-right: 3px;
+}
diff --git a/app/controllers/annotations_controller.rb b/app/controllers/annotations_controller.rb
index 44be27521..d3996e18e 100755
--- a/app/controllers/annotations_controller.rb
+++ b/app/controllers/annotations_controller.rb
@@ -83,7 +83,7 @@ def annotation_params
params[:annotation][:submitted_by] = @current_user.email
params.require(:annotation).permit(:filename, :position, :line, :submitted_by,
:comment, :shared_comment, :global_comment, :value,
- :problem_id, :submission_id, :coordinate)
+ :problem_id, :submission_id, :coordinate, :rubric_item_id)
end
def set_annotation
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 150bf0bd9..2d643decd 100755
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -198,8 +198,7 @@ def authorize_user_for_course
invalid_cud = !@cud.valid?
nicknameless_student = @cud.student? && @cud.nickname.blank?
in_edit_or_unsudo = (params[:controller] == "course_user_data") &&
- (params[:action] == "edit" || params[:action] == "update" ||
- params[:action] == "unsudo")
+ ["edit", "update", "unsudo"].include?(params[:action])
return unless (invalid_cud || nicknameless_student) && !in_edit_or_unsudo
diff --git a/app/controllers/rubric_item_assignments_controller.rb b/app/controllers/rubric_item_assignments_controller.rb
new file mode 100644
index 000000000..dae9ff53b
--- /dev/null
+++ b/app/controllers/rubric_item_assignments_controller.rb
@@ -0,0 +1,66 @@
+class RubricItemAssignmentsController < ApplicationController
+ before_action :set_assessment
+ before_action :set_submission
+ before_action :set_rubric_item
+
+ # POST /:course/rubric_item_assignments/:rubric_item_id/toggle
+ action_auth_level :toggle, :course_assistant
+ def toggle
+ # Find or create the rubric item assignment
+ assignment = RubricItemAssignment.find_or_initialize_by(
+ submission_id: @submission.id,
+ rubric_item_id: @rubric_item.id
+ )
+
+ # Toggle the assigned status
+ assignment.assigned = !assignment.assigned
+
+ # Save the assignment status
+ if assignment.save
+ # Find or initialize the score for this submission and problem
+ score = Score.find_or_initialize_by(
+ submission_id: @submission.id,
+ problem_id: @rubric_item.problem_id
+ )
+
+ # Set grader if not already set
+ if score.new_record? || score.grader_id.nil?
+ score.grader_id = @cud.id
+ end
+
+ # Calculate the sum of all assigned rubric items for this problem
+ assigned_points = @submission.rubric_item_assignments
+ .joins(:rubric_item)
+ .where(assigned: true, rubric_items: {
+ problem_id: @rubric_item.problem_id
+ })
+ .sum('rubric_items.points')
+
+ # Update the score
+ score.score = assigned_points
+ score.save
+
+ flash[:success] = assignment.assigned ? "Rubric item assigned" : "Rubric item unassigned"
+
+ # Redirect back to the submission view
+ else
+ flash[:error] = "Failed to update rubric item assignment"
+ end
+ redirect_to view_course_assessment_submission_path(@course, @assessment, @submission,
+ params[:header_position])
+ end
+
+private
+
+ def set_assessment
+ @assessment = @course.assessments.find_by(name: params[:assessment_id])
+ end
+
+ def set_submission
+ @submission = @assessment.submissions.find(params[:submission_id])
+ end
+
+ def set_rubric_item
+ @rubric_item = RubricItem.find(params[:rubric_item_id])
+ end
+end
diff --git a/app/controllers/rubric_items_controller.rb b/app/controllers/rubric_items_controller.rb
new file mode 100644
index 000000000..a0dc5e343
--- /dev/null
+++ b/app/controllers/rubric_items_controller.rb
@@ -0,0 +1,125 @@
+class RubricItemsController < ApplicationController
+ before_action :set_assessment
+ before_action :set_problem, except: [:toggle]
+ before_action :set_rubric_item, only: [:edit, :update, :destroy, :toggle]
+ before_action :set_submission, only: [:toggle]
+ before_action :set_problem_from_rubric_item, only: [:toggle]
+
+ action_auth_level :new, :instructor
+ def new
+ @rubric_item = @problem.rubric_items.new
+ end
+
+ action_auth_level :create, :instructor
+ def create
+ @rubric_item = @problem.rubric_items.new(rubric_item_params)
+ @rubric_item.order = @problem.rubric_items.count
+
+ if @rubric_item.save
+ flash[:success] = "Rubric item created successfully"
+ redirect_to edit_course_assessment_problem_path(@course, @assessment, @problem)
+ else
+ flash[:error] = "Error creating rubric item"
+ @rubric_item.errors.full_messages.each do |msg|
+ flash[:error] += " #{msg}"
+ end
+ flash[:html_safe] = true
+ render :new
+ end
+ end
+
+ action_auth_level :edit, :instructor
+ def edit; end
+
+ action_auth_level :update, :instructor
+ def update
+ if @rubric_item.update(rubric_item_params)
+ flash[:success] = "Rubric item updated successfully"
+ redirect_to edit_course_assessment_problem_path(@course, @assessment, @problem)
+ else
+ flash[:error] = "Error updating rubric item"
+ @rubric_item.errors.full_messages.each do |msg|
+ flash[:error] += " #{msg}"
+ end
+ flash[:html_safe] = true
+ render :edit
+ end
+ end
+
+ action_auth_level :destroy, :instructor
+ def destroy
+ @rubric_item.destroy
+ flash[:success] = "Rubric item deleted successfully"
+ redirect_to edit_course_assessment_problem_path(@course, @assessment, @problem)
+ end
+
+ action_auth_level :toggle, :course_assistant
+ def toggle
+ # Store previous state for better feedback
+ was_assigned = RubricItemAssignment.find_by(
+ submission_id: @submission.id,
+ rubric_item_id: @rubric_item.id
+ )&.assigned || false
+
+ # Find or create the rubric item assignment
+ assignment = RubricItemAssignment.find_or_initialize_by(
+ submission_id: @submission.id,
+ rubric_item_id: @rubric_item.id
+ )
+
+ # Toggle the assigned status
+ assignment.assigned = !assignment.assigned
+
+ # Save the assignment status and get the final score
+ if assignment.save
+ # Get the updated score after the save (which triggers recalculation)
+ score = Score.find_by(submission_id: @submission.id, problem_id: @rubric_item.problem_id)
+ score&.score || 0
+ @problem.max_score || 0
+
+ # Points change message
+ point_change = was_assigned ? -@rubric_item.points : @rubric_item.points
+ point_change >= 0 ? "+#{point_change}" : point_change.to_s
+
+ else
+ flash[:error] = "Failed to update rubric item assignment"
+ end
+
+ # Redirect with a cache-busting parameter to ensure fresh data is loaded
+ redirect_to view_course_assessment_submission_path(
+ @course,
+ @assessment,
+ @submission,
+ params[:header_position],
+ refresh: Time.now.to_i
+ )
+ end
+
+private
+
+ def set_problem
+ @problem = @assessment.problems.find(params[:problem_id])
+ end
+
+ def set_rubric_item
+ # For toggle action, find rubric item directly without going through problem
+ @rubric_item = if action_name == 'toggle'
+ RubricItem.find(params[:id])
+ else
+ @problem.rubric_items.find(params[:id])
+ end
+ end
+
+ # Add this new method to set the problem from the rubric item when toggling
+ def set_problem_from_rubric_item
+ @problem = @rubric_item.problem
+ end
+
+ def set_submission
+ @submission = @assessment.submissions.find(params[:submission_id])
+ end
+
+ def rubric_item_params
+ params.require(:rubric_item).permit(:description, :points, :order)
+ end
+end
diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb
index 9496bb399..2f63b6df5 100755
--- a/app/controllers/submissions_controller.rb
+++ b/app/controllers/submissions_controller.rb
@@ -581,6 +581,61 @@ def view
}]
end
+ # Make sure @problems is initialized early
+ @problems = @assessment.problems.ordered.to_a
+
+ # Initialize problem-related hashes
+ @problemAnnotations = {}
+ @problemMaxScores = {}
+ @problemScores = {}
+ @problemNameToId = {}
+
+ # Initialize all problems
+ @problems.each do |problem|
+ @problemAnnotations[problem.name] ||= []
+ @problemMaxScores[problem.name] ||= problem.max_score
+ @problemScores[problem.name] ||= 0
+ @problemNameToId[problem.name] ||= problem.id
+ end
+
+ # Pass problems with rubric items to the view template for JavaScript
+ @problems_with_rubric_items = @problems.map do |problem|
+ problem_data = problem.as_json
+ problem_data["rubric_items"] = problem.rubric_items.map do |item|
+ {
+ id: item.id,
+ description: item.description,
+ points: item.points
+ }
+ end
+ problem_data
+ end
+
+ # For the annotation form, if we're viewing a specific file and line
+ if params[:header_position].present? && params[:line].present?
+ # Get the problem_id from the URL query parameters if available
+ problem_id = params[:problem_id]
+
+ # If not provided in URL, try to figure out which problem we're annotating
+ if !problem_id && !@problemNameToId.empty? && !@problemNameToId.values.first.nil?
+ # Default to first problem if none specified
+ problem_id = @problemNameToId.values.first
+ end
+
+ # Set the problem for the annotation form if we found one
+ @problem = Problem.find_by(id: problem_id) if problem_id
+ end
+
+ # Refresh problem scores after a rubric item toggle - do this early
+ if params[:refresh].present?
+ @problems.each do |problem|
+ score = Score.find_by(submission_id: @submission.id, problem_id: problem.id)
+ if score
+ @problemScores[problem.name] = score.score || 0
+ end
+ end
+ end
+
viewing_autograder_output = params.include?(:header_position) &&
(params[:header_position].to_i == -1)
@@ -713,6 +768,14 @@ def view
end
end
+ # Refresh problem scores after a rubric item toggle
+ if params[:refresh].present?
+ @problems.each do |problem|
+ score = Score.find_by(submission_id: @submission.id, problem_id: problem.id)
+ @problemScores[problem.name] = score&.score || 0
+ end
+ end
+
@annotations = @submission.annotations.to_a
unless @submission.group_key.empty?
group_submissions = @submission.group_associated_submissions
@@ -727,12 +790,17 @@ def view
@annotations = []
end
- files = if Archive.archive? @filename
- Archive.get_files(@filename)
- end
+ if Archive.archive? @filename
+ Archive.get_files(@filename)
+ end
@problems = @assessment.problems.ordered.to_a
+ # Preload rubric items and their assignments for this submission
+ @rubric_item_assignments = RubricItemAssignment.includes(:rubric_item)
+ .where(submission_id: @submission.id)
+ .index_by(&:rubric_item_id)
+
# Allow scores to be assessed by the view
@scores = Score.where(submission_id: @submission.id)
@@ -749,41 +817,74 @@ def view
end
end
- # initialize all problems
+ # Process problems for the annotation pane
+ @problem_data = []
@problems.each do |problem|
- # exclude problems that were autograded
- # so that we do not render the header in the annotation pane
- next if autogradedProblems.key? problem.id
+ # skip problems that were autograded
+ next if autogradedProblems.key?(problem.id)
+
+ problem_score = @scores.find { |s| s.problem_id == problem.id }&.score || 0
+
+ # Prepare basic problem data
+ problem_data = {
+ id: problem.id,
+ name: problem.name,
+ max_score: problem.max_score,
+ score: problem_score,
+ has_rubric: problem.rubric_items.any?,
+ rubric_items: []
+ }
+ # Add to collections for backward compatibility
@problemAnnotations[problem.name] ||= []
@problemMaxScores[problem.name] ||= problem.max_score
- @problemScores[problem.name] ||= 0
+ @problemScores[problem.name] ||= problem_score
@problemNameToId[problem.name] ||= problem.id
- end
- # extract information from annotations
- @annotations.each do |annotation|
- description = annotation.comment
- value = annotation.value || 0
- line = annotation.line
- problem = if annotation.problem
- annotation.problem.name
- else
- annotation.problem_id ? "Deleted Problem(s)" : "Global"
- end
- shared = annotation.shared_comment
- global = annotation.global_comment
- filename = get_correct_filename(annotation, files, @submission)
+ # Process annotations for this problem
+ annotations_data = @annotations.select { |a| a.problem_id == problem.id }
+ annotations_data.select(&:global_comment)
+ file_annotations = annotations_data.reject(&:global_comment)
+ file_annotations.group_by { |a| a.filename || "" }
+
+ # Process rubric items and their annotations
+ problem_data[:rubric_items] = problem.rubric_items.map do |item|
+ item_data = {
+ id: item.id,
+ description: item.description,
+ points: item.points,
+ assigned: false,
+ global_annotations: [],
+ annotations_by_file: {}
+ }
+
+ # Check assignment status
+ assignment = RubricItemAssignment.find_or_initialize_by(
+ rubric_item_id: item.id,
+ submission_id: @submission.id
+ )
+ item_data[:assigned] = assignment.assigned
+
+ # Get annotations linked to this rubric item
+ item_annotations = annotations_data.select { |a| a.rubric_item_id == item.id }
+ item_global_annotations = item_annotations.select(&:global_comment)
+ item_file_annotations = item_annotations.reject(&:global_comment)
+
+ item_data[:global_annotations] = item_global_annotations
+ item_data[:annotations_by_file] = item_file_annotations.group_by { |a| a.filename || "" }
+
+ item_data
+ end
- # To handle annotations on deleted problems
- @problemAnnotations[problem] ||= []
- @problemMaxScores[problem] ||= 0
- @problemScores[problem] ||= 0
- @problemNameToId[problem] ||= -1
+ # Process non-rubric annotations
+ non_rubric_annotations = annotations_data.select { |a| a.rubric_item_id.nil? }
+ problem_data[:global_annotations] = non_rubric_annotations.select(&:global_comment)
+ problem_data[:annotations_by_file] = non_rubric_annotations.
+ reject(&:global_comment).group_by { |a|
+ a.filename || ""
+ }
- @problemAnnotations[problem] << [description, value, line, annotation.submitted_by,
- annotation.id, annotation.position, filename, shared, global]
- @problemScores[problem] += value
+ @problem_data << problem_data
end
# Process @problemSummaries
@@ -1015,7 +1116,7 @@ def get_file(submission, header_position)
def is_binary_file?(file)
mm = MimeMagic.by_magic(file)
- mm.present? && (!mm.text? && (mm.subtype != "pdf"))
+ mm.present? && !mm.text? && (mm.subtype != "pdf")
end
def set_manage_submissions_breadcrumb
diff --git a/app/helpers/annotations_helper.rb b/app/helpers/annotations_helper.rb
new file mode 100644
index 000000000..d2b9bbe60
--- /dev/null
+++ b/app/helpers/annotations_helper.rb
@@ -0,0 +1,99 @@
+module AnnotationsHelper
+ # Group annotations by rubric item
+ def group_annotations_by_rubric_item(problem_annotations)
+ problem_annotations.group_by(&:rubric_item_id)
+ end
+
+ # Separate global annotations from file-specific ones
+ def separate_global_and_file_annotations(annotations)
+ global_annotations = annotations.select(&:global_comment)
+ file_annotations = annotations.reject(&:global_comment)
+ [global_annotations, file_annotations]
+ end
+
+ # Group annotations by filename
+ def group_annotations_by_filename(annotations)
+ annotations.group_by { |a| a.filename || "" }
+ end
+
+ # Get all annotations for a problem
+ def get_problem_annotations(problem_id, global_annotations_data, annotations_by_file_data)
+ problem_annotations = []
+
+ # Add global annotations for this problem
+ if global_annotations_data.present?
+ global_annotations_data.each do |annotation_data|
+ id = annotation_data[4]
+ annotation = Annotation.find_by(id: id)
+ problem_annotations << annotation if annotation && annotation.problem_id == problem_id
+ end
+ end
+
+ # Add file-specific annotations for this problem
+ annotations_by_file_data.each do |_, file_annotations|
+ file_annotations.each do |annotation_data|
+ id = annotation_data[4]
+ annotation = Annotation.find_by(id: id)
+ problem_annotations << annotation if annotation && annotation.problem_id == problem_id
+ end
+ end
+
+ problem_annotations.compact
+ end
+
+ # Get rubric items with their annotations
+ def get_rubric_items_with_annotations(problem, submission, global_annotations_data, annotations_by_file_data)
+ problem_annotations = get_problem_annotations(problem.id, global_annotations_data, annotations_by_file_data)
+ annotations_by_rubric_item = group_annotations_by_rubric_item(problem_annotations)
+
+ problem.rubric_items.map do |item|
+ assignment = RubricItemAssignment.find_or_initialize_by(
+ rubric_item_id: item.id,
+ submission_id: submission.id
+ )
+
+ rubric_annotations = annotations_by_rubric_item[item.id] || []
+ global_annotations, file_annotations = separate_global_and_file_annotations(rubric_annotations)
+ annotations_by_filename = group_annotations_by_filename(file_annotations)
+
+ {
+ rubric_item: item,
+ assignment: assignment,
+ global_annotations: global_annotations,
+ annotations_by_filename: annotations_by_filename
+ }
+ end
+ end
+
+ # Get non-rubric annotations for a problem
+ def get_non_rubric_annotations(problem_id, global_annotations_data, annotations_by_file_data)
+ # Process global annotations
+ filtered_global_annotations = []
+ global_annotations_data.each do |description, value, line, user, id, position, filename, shared, global|
+ annotation = Annotation.find_by(id: id)
+ if annotation && annotation.problem_id == problem_id && annotation.rubric_item_id.nil?
+ filtered_global_annotations << [description, value, line, user, id, position, filename, shared, global]
+ end
+ end
+
+ # Process file annotations
+ filtered_annotations_by_file = {}
+ annotations_by_file_data.each do |filename, annotations|
+ file_annotations = []
+ annotations.each do |description, value, line, user, id, position, filename, global|
+ annotation = Annotation.find_by(id: id)
+ if annotation && annotation.problem_id == problem_id && annotation.rubric_item_id.nil?
+ file_annotations << [description, value, line, user, id, position, filename, global]
+ end
+ end
+ filtered_annotations_by_file[filename] = file_annotations unless file_annotations.empty?
+ end
+
+ [filtered_global_annotations, filtered_annotations_by_file]
+ end
+
+ # Clean filename for display (extract basename)
+ def clean_filename(filename)
+ File.basename(filename.to_s)
+ end
+end
diff --git a/app/models/annotation.rb b/app/models/annotation.rb
index 2df2ab676..8d1a0adbe 100755
--- a/app/models/annotation.rb
+++ b/app/models/annotation.rb
@@ -4,8 +4,11 @@
# score calculations in the future.
#
class Annotation < ApplicationRecord
+ include ScoreCalculation
+
belongs_to :submission
belongs_to :problem
+ belongs_to :rubric_item, optional: true
validates :comment, :value, :filename, :submission_id, :problem_id, presence: true
@@ -23,14 +26,12 @@ def as_text
end
end
- # Update all non-autograded scores with the following formula:
- # score_p = max_score_p + sum of annotations for problem
+ # Update all non-autograded scores with combined rubric items and annotations
def update_non_autograded_score
- # Get score for submission, or create one if it does not already exist
- # Previously, scores would be created when instructors add a score
- # and save on the gradebook
- score = Score.find_or_initialize_by_submission_id_and_problem_id(
- submission_id, problem_id
+ # Get score for submission
+ score = Score.find_or_initialize_by(
+ submission_id: submission_id,
+ problem_id: problem_id
)
# Associated problem was deleted
@@ -39,55 +40,7 @@ def update_non_autograded_score
# Ensure that problem is non-autograded
return if score.grader_id == 0
- # If score was newly-created, we need to add a grader_id to score
- if score.grader.nil?
- score.grader_id = CourseUserDatum.find_by(user_id: User.find_by(email: submitted_by).id,
- course_id: submission.assessment.course_id).id
- end
-
- # Obtain sum of all annotations for this score
- if submission.group_key.empty?
- annotation_delta = Annotation
- .where(submission_id:,
- problem_id:)
- .map(&:value).sum { |v| v.nil? ? 0 : v }
- else
- submissions = Submission.where(group_key: submission.group_key)
- annotation_delta = 0
- submissions.each do |submission|
- annotation_delta += submission.annotations.where(problem_id:)
- .map(&:value).sum { |v| v.nil? ? 0 : v }
- end
- end
-
- # Default score to 0 if problem.max_score is nil
- max_score = score.problem.max_score || 0
-
- # Check if positive grading is enabled for this assessment
- new_score = if submission.assessment.is_positive_grading
- annotation_delta
- else
- max_score + annotation_delta
- end
-
- # Update score
- if submission.group_key.empty?
- score.update!(score: new_score)
- else
- # Find all scores
- group_submissions = submission.group_associated_submissions
- scores = [score]
- group_submissions.each do |group_submission|
- group_score = Score
- .find_or_initialize_by_submission_id_and_problem_id(
- group_submission.id, problem_id
- )
- group_score.grader_id = score.grader_id
- scores.append(group_score)
- end
- scores.each do |group_score|
- group_score.update!(score: new_score)
- end
- end
+ # Update the score using the shared implementation
+ update_score
end
end
diff --git a/app/models/concerns/score_calculation.rb b/app/models/concerns/score_calculation.rb
new file mode 100644
index 000000000..39a6f7e08
--- /dev/null
+++ b/app/models/concerns/score_calculation.rb
@@ -0,0 +1,73 @@
+module ScoreCalculation
+ extend ActiveSupport::Concern
+
+ # Instance methods for models that include this concern
+ def update_score
+ # Find the score for this submission and problem
+ score = Score.find_or_initialize_by(
+ submission_id: submission_id,
+ problem_id: problem_id_for_score
+ )
+
+ # Set grader if not already set
+ if score.grader_id.nil?
+ instructor = submission.course_user_datum.course.course_user_data.find_by(instructor: true)
+ score.grader_id = instructor ? instructor.id : submission.course_user_datum_id
+ end
+
+ # Calculate the total score from both annotations and rubric items
+ total_score = calculate_total_score
+
+ # Update the score
+ score.update(score: total_score)
+
+ # Update cached scores
+ submission.invalidate_raw_score
+ submission.update_latest_submission
+
+ total_score
+ end
+
+ def calculate_total_score
+ # Get problem ID depending on whether this is an annotation or rubric item assignment
+ problem_id = problem_id_for_score
+ problem = Problem.find(problem_id)
+
+ # Calculate rubric item points
+ rubric_item_points = submission.rubric_item_assignments
+ .joins(:rubric_item)
+ .where(assigned: true, rubric_items: { problem_id: problem_id })
+ .sum('rubric_items.points')
+
+ # Calculate annotation points
+ annotation_points = 0
+
+ if submission.group_key.empty?
+ annotation_points = Annotation.where(submission_id: submission_id, problem_id: problem_id)
+ .sum(:value)
+ else
+ # For group submissions, include annotations from all group submissions
+ submissions = Submission.where(group_key: submission.group_key)
+ submissions.each do |sub|
+ annotation_points += Annotation.where(submission_id: sub.id, problem_id: problem_id)
+ .sum(:value)
+ end
+ end
+
+ # Include the problem's max_score in the final score calculation
+ annotation_points + rubric_item_points
+ end
+
+ private
+
+ # Helper to get the correct problem_id depending on what type of object this is
+ def problem_id_for_score
+ if self.is_a?(Annotation)
+ self.problem_id
+ elsif self.is_a?(RubricItemAssignment)
+ self.rubric_item.problem_id
+ else
+ raise "Unknown type for ScoreCalculation"
+ end
+ end
+end
diff --git a/app/models/course_user_datum.rb b/app/models/course_user_datum.rb
index 682d65987..7b63f9d5b 100755
--- a/app/models/course_user_datum.rb
+++ b/app/models/course_user_datum.rb
@@ -47,7 +47,7 @@ def self.conditions_by_like(value, *columns)
def strip_html
CourseUserDatum.content_columns.each do |column|
- next unless column.type == :string || column.type == :text
+ next unless [:string, :text].include?(column.type)
self[column.name] = self[column.name].gsub(/, "") unless self[column.name].nil?
end
diff --git a/app/models/problem.rb b/app/models/problem.rb
index bccdb1b3b..23e93d443 100755
--- a/app/models/problem.rb
+++ b/app/models/problem.rb
@@ -9,6 +9,7 @@ class Problem < ApplicationRecord
has_many :scores, dependent: :delete_all
belongs_to :assessment, touch: true
has_many :annotations, dependent: :destroy
+ has_many :rubric_items, dependent: :destroy
validates :name, :max_score, presence: true
validates :name, uniqueness: { case_sensitive: false, scope: :assessment_id }
diff --git a/app/models/rubric_item.rb b/app/models/rubric_item.rb
new file mode 100644
index 000000000..a3ac82cfd
--- /dev/null
+++ b/app/models/rubric_item.rb
@@ -0,0 +1,18 @@
+class RubricItem < ApplicationRecord
+ belongs_to :problem
+ has_many :rubric_item_assignments, dependent: :destroy
+ has_many :submissions, through: :rubric_item_assignments
+ has_many :annotations, dependent: :nullify
+
+ validates :description, :points, :order, presence: true
+ validates :points, numericality: true
+ validates :order, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :order, uniqueness: { scope: :problem_id }
+
+ default_scope { order(order: :asc) }
+
+ # Check if this rubric item is assigned to a specific submission
+ def assigned_to?(submission)
+ rubric_item_assignments.where(submission: submission, assigned: true).exists?
+ end
+end
diff --git a/app/models/rubric_item_assignment.rb b/app/models/rubric_item_assignment.rb
new file mode 100644
index 000000000..24eafabc9
--- /dev/null
+++ b/app/models/rubric_item_assignment.rb
@@ -0,0 +1,10 @@
+class RubricItemAssignment < ApplicationRecord
+ include ScoreCalculation
+
+ belongs_to :rubric_item
+ belongs_to :submission
+
+ validates :rubric_item_id, uniqueness: { scope: :submission_id }
+
+ after_save :update_score
+end
diff --git a/app/models/submission.rb b/app/models/submission.rb
index 056c799d1..7036a6ce2 100755
--- a/app/models/submission.rb
+++ b/app/models/submission.rb
@@ -18,6 +18,11 @@ class Submission < ApplicationRecord
accepts_nested_attributes_for :tweak, allow_destroy: true
has_one :assessment_user_datum, foreign_key: "latest_submission_id"
+ has_many :rubric_item_assignments, dependent: :destroy
+ has_many :assigned_rubric_items, -> { where(rubric_item_assignments: { assigned: true }) },
+ through: :rubric_item_assignments,
+ source: :rubric_item
+
validate :allowed?, on: :create
validates_associated :assessment
validate :user_and_assessment_in_same_course
diff --git a/app/views/annotations/_global_annotation.html.erb b/app/views/annotations/_global_annotation.html.erb
new file mode 100644
index 000000000..3a84d35e8
--- /dev/null
+++ b/app/views/annotations/_global_annotation.html.erb
@@ -0,0 +1,32 @@
+
+
+ ">
+ <%= plus_fix(annotation.value) %>
+
+
+
+ <%= annotation.comment %>
+
+ <% if @cud.instructor? or @cud.course_assistant? %>
+
+ <% end %>
+
diff --git a/app/views/annotations/_line_annotation.html.erb b/app/views/annotations/_line_annotation.html.erb
new file mode 100644
index 000000000..276ff9ddc
--- /dev/null
+++ b/app/views/annotations/_line_annotation.html.erb
@@ -0,0 +1,25 @@
+
+ <%= link_to(
+ url_for([:view, @course, @assessment, @submission, { header_position: annotation.position.nil? ? 0 : annotation.position, line: annotation.line.nil? ? 1 : annotation.line + 1 }]),
+ class: 'descript-link',
+ "data-header_position": annotation.position.nil? ? 0 : annotation.position,
+ "data-line": annotation.line.nil? ? 1 : annotation.line + 1,
+ remote: true,
+ ) do %>
+
+ ">
+ <% unless annotation.line.nil? %>
+ Line <%= annotation.line + 1 %>:
+ <% end %>
+ <%= plus_fix(annotation.value) %>
+
+
+
+ <%= annotation.comment %>
+
+ <% end %>
+
diff --git a/app/views/problems/_fields.html.erb b/app/views/problems/_fields.html.erb
index 2d65455cf..e88ef436e 100755
--- a/app/views/problems/_fields.html.erb
+++ b/app/views/problems/_fields.html.erb
@@ -21,6 +21,41 @@ unchecked." %>
<%= f.check_box :starred, help_text: "By default, all problems are
\"not starred\". Starred problems are displayed first in the problems dropdown when creating annotations." %>
+<% if @problem %>
+
+
Rubric Items
+
+
+
+ Description
+ Points
+ Actions
+
+
+
+ <% @problem.rubric_items.each do |item| %>
+
+ <%= item.description %>
+ <%= item.points %>
+
+ <%= link_to "mode_edit ".html_safe,
+ edit_course_assessment_problem_rubric_item_path(@course, @assessment, @problem, item),
+ { class: "small" } %>
+ <%= link_to "delete ".html_safe,
+ [@course, @assessment, @problem, item],
+ method: :delete,
+ class: "small",
+ data: { confirm: "Are you sure you want to delete this rubric item?" } %>
+
+
+ <% end %>
+
+
+
+ <%= link_to "Add Rubric Item", new_course_assessment_problem_rubric_item_path(@course, @assessment, @problem), { class: "btn" } %>
+
+<% end %>
+
<%= f.submit "Save Problem", { class: "btn primary" } %>
<% if @problem %>
@@ -30,4 +65,3 @@ unchecked." %>
data: { confirm: "Deleting will delete all associated problem data (such as scores and annotations) and cannot be undone. Are you sure you want to delete this problem?" } %>
<% end %>
-
diff --git a/app/views/rubric_items/edit.html.erb b/app/views/rubric_items/edit.html.erb
new file mode 100644
index 000000000..fe678643f
--- /dev/null
+++ b/app/views/rubric_items/edit.html.erb
@@ -0,0 +1,22 @@
+<%# For navigation breadcrumbs %>
+<% @title = "Edit Rubric Item" %>
+
+<% content_for :stylesheets do %>
+ <%= stylesheet_link_tag "problems" %>
+<% end %>
+
+Edit Rubric Item
+<%= form_for [@course, @assessment, @problem, @rubric_item], builder: FormBuilderWithDateTimeInput do |f| %>
+ <%= f.text_field :description, help_text: "Description of the rubric item.", placeholder: "Description", required: true %>
+
+ <%= f.number_field :points, help_text: "Points for this rubric item.", placeholder: "0", step: "any", required: true %>
+
+
+ <%= f.submit "Save Rubric Item", { class: "btn primary" } %>
+
+ <%= link_to "Delete this Rubric Item", [@course, @assessment, @problem, @rubric_item],
+ method: :delete, class: 'btn btn-danger',
+ data: { confirm: "Are you sure you want to delete this rubric item?" } %>
+
+
+<% end %>
diff --git a/app/views/rubric_items/new.html.erb b/app/views/rubric_items/new.html.erb
new file mode 100644
index 000000000..3ded7093a
--- /dev/null
+++ b/app/views/rubric_items/new.html.erb
@@ -0,0 +1,17 @@
+<%# For navigation breadcrumbs %>
+<% @title = "New Rubric Item" %>
+
+<% content_for :stylesheets do %>
+ <%= stylesheet_link_tag "problems" %>
+<% end %>
+
+Create New Rubric Item
+<%= form_for [@course, @assessment, @problem, @rubric_item], builder: FormBuilderWithDateTimeInput do |f| %>
+ <%= f.text_field :description, help_text: "Description of the rubric item.", placeholder: "Description", required: true %>
+
+ <%= f.number_field :points, help_text: "Points for this rubric item.", placeholder: "0", step: "any", required: true %>
+
+
+ <%= f.submit "Save Rubric Item", { class: "btn primary" } %>
+
+<% end %>
diff --git a/app/views/submissions/_annotation_form.html.erb b/app/views/submissions/_annotation_form.html.erb
index e3dce1161..78532c043 100644
--- a/app/views/submissions/_annotation_form.html.erb
+++ b/app/views/submissions/_annotation_form.html.erb
@@ -38,12 +38,23 @@
-
+
<% end %>
+ <% unless global %>
+
+
+
+ -- Select a problem first --
+
+ Optionally associate this annotation with a rubric item
+
+
+ <% end %>
+