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 Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
### 🚨 Breaking changes

### ✨ New features and improvements
- Added CSV upload support for criterion marks in the assignment Grades tab (#8008)
- Added a confirm dialog when a student tries to submit work after the deadline has passed (#8003)
- Added a confirm dialog to the Upload Scans form that appears when no template divisions are assigned to the selected exam template (#7993)
- Migrated `MarkingSchemesTable` component to React Table V8 (#7985)
Expand Down
13 changes: 13 additions & 0 deletions app/controllers/assignments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,19 @@ def summary
end
end

def upload_grades
@assignment = record
begin
data = process_file_upload(['.csv'])
rescue StandardError => e
flash_message(:error, e.message)
else
result = @assignment.import_marks_from_csv(data[:contents], params[:overwrite], current_role)
flash_csv_result(result)
end
redirect_to action: 'summary', id: @assignment.id
end

def download_test_results
@assignment = record
respond_to do |format|
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import React from "react";
import Modal from "react-modal";

class AssignmentGradesUploadModal extends React.Component {
componentDidMount() {
Modal.setAppElement("body");
}

authenticityToken() {
return document.querySelector("meta[name='csrf-token']")?.content || "";
}

render() {
return (
<Modal
className="react-modal markus-dialog"
isOpen={this.props.isOpen}
onRequestClose={this.props.onRequestClose}
>
<h2>
{I18n.t("upload_the", {
item: I18n.t("assignments.grades"),
})}
</h2>
<form
action={Routes.upload_grades_course_assignment_path(
this.props.course_id,
this.props.assignment_id
)}
encType="multipart/form-data"
method="post"
>
<div className="modal-container-vertical">
<input type="hidden" name="authenticity_token" value={this.authenticityToken()} />
<p>
<input type="file" name="upload_file" required={true} accept=".csv" />
</p>
<p>
<label htmlFor="encoding">{I18n.t("encoding")}</label>
<select id="encoding" name="encoding" defaultValue="UTF-8">
{this.props.encodings.map(([label, value]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</p>
<p>
<label htmlFor="overwrite">
<input type="checkbox" id="overwrite" name="overwrite" />{" "}
{I18n.t("assignments.upload_grades.overwrite")}
</label>
</p>
<section className="dialog-actions">
<input
type="submit"
value={I18n.t("upload")}
data-disable-with={I18n.t("uploading_please_wait")}
/>
<input onClick={this.props.onRequestClose} type="reset" value={I18n.t("cancel")} />
</section>
</div>
</form>
</Modal>
);
}
}

export default AssignmentGradesUploadModal;
2 changes: 2 additions & 0 deletions app/javascript/Components/assignment_summary.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class AssignmentSummary extends React.Component {
course_id={this.props.course_id}
assignment_id={this.props.assessment_id}
is_instructor={this.props.is_instructor}
encodings={this.props.encodings}
lti_deployments={this.props.lti_deployments}
/>
</TabPanel>
Expand All @@ -38,6 +39,7 @@ class AssignmentSummary extends React.Component {
course_id={this.props.course_id}
assignment_id={this.props.assessment_id}
is_instructor={this.props.is_instructor}
encodings={this.props.encodings}
lti_deployments={this.props.lti_deployments}
/>
);
Expand Down
17 changes: 17 additions & 0 deletions app/javascript/Components/assignment_summary_table.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from "react";
import {getMarkingStates, selectFilter} from "./Helpers/table_helpers";

import AssignmentGradesUploadModal from "./Modals/assignment_grades_upload_modal";
import DownloadTestResultsModal from "./Modals/download_test_results_modal";
import LtiGradeModal from "./Modals/send_lti_grades_modal";
import {createColumnHelper} from "@tanstack/react-table";
Expand All @@ -20,6 +21,7 @@ export class AssignmentSummaryTable extends React.Component {
marking_states: markingStates,
markingStateFilter: "all",
showDownloadTestsModal: false,
showGradesUploadModal: false,
showLtiGradeModal: false,
lti_deployments: [],
columnFilters: [{id: "inactive", value: false}],
Expand Down Expand Up @@ -361,6 +363,10 @@ export class AssignmentSummaryTable extends React.Component {
this.setState({showDownloadTestsModal: true});
};

onGradesUploadModal = () => {
this.setState({showGradesUploadModal: true});
};

onLtiGradeModal = () => {
this.setState({showLtiGradeModal: true});
};
Expand Down Expand Up @@ -433,6 +439,10 @@ export class AssignmentSummaryTable extends React.Component {
{I18n.t("download")}
</button>
</form>
<button type="button" name="upload" onClick={this.onGradesUploadModal}>
<i className="fa-solid fa-upload" aria-hidden="true" />
{I18n.t("upload")}
</button>
{this.state.enable_test && (
<button type="submit" name="download_tests" onClick={this.onDownloadTestsModal}>
{I18n.t("download_the", {
Expand Down Expand Up @@ -462,6 +472,13 @@ export class AssignmentSummaryTable extends React.Component {
}}
loading={this.state.loading}
/>
<AssignmentGradesUploadModal
course_id={this.props.course_id}
assignment_id={this.props.assignment_id}
encodings={this.props.encodings || []}
isOpen={this.state.showGradesUploadModal}
onRequestClose={() => this.setState({showGradesUploadModal: false})}
/>
<DownloadTestResultsModal
course_id={this.props.course_id}
assignment_id={this.props.assignment_id}
Expand Down
120 changes: 120 additions & 0 deletions app/models/assignment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -866,6 +866,64 @@ def summary_csv(role)
end
end

def import_marks_from_csv(grades_data, overwrite, role)
results_by_group_name = current_results
.joins(grouping: :group)
.includes(:marks, grouping: :group)
.index_by { |result| result.grouping.group.group_name }
results_by_user_name = current_results
.joins(grouping: { accepted_students: :user })
.includes(:marks)
.pluck('users.user_name', 'results.id')
.to_h
results_by_id = results_by_group_name.values.index_by(&:id)
criteria_by_name = criteria_by_upload_name

headers = nil
criterion_columns = nil
group_name_index = nil
user_name_index = nil

MarkusCsv.parse(grades_data, header_count: 2) do |row|
next if row.empty?

if headers.nil?
headers = row
group_name_index = headers.index(Group.human_attribute_name(:group_name)) || 0
user_name_index = headers.index(User.human_attribute_name(:user_name)) || 1
next
elsif criterion_columns.nil?
begin
criterion_columns = assignment_upload_criterion_columns(headers, row, criteria_by_name)
rescue CsvInvalidLineError
criterion_columns = :invalid
raise
end
next
end

raise CsvInvalidLineError if criterion_columns == :invalid

result = result_for_uploaded_marks_row(row, group_name_index, user_name_index,
results_by_group_name, results_by_user_name, results_by_id)
raise CsvInvalidLineError if result.nil? || result.released_to_students?

Mark.transaction do
criterion_columns.each do |column_index, criterion|
mark = result.marks.find_or_initialize_by(criterion: criterion)
next if !overwrite && !mark.mark.nil?

mark_value = parse_uploaded_mark(row[column_index])
unless mark.update(mark: mark_value,
override: !(mark_value.nil? && mark.deductive_annotations_absent?),
last_updated_by: role)
raise CsvInvalidLineError, mark.errors.full_messages.to_sentence
end
end
end
end
end

# Returns an array of [mark, max_mark].
def get_marks_list(submission)
criteria.map do |criterion|
Expand All @@ -881,6 +939,68 @@ def next_criterion_position
criteria.exists? ? criteria.last.position + 1 : 1
end

def criteria_by_upload_name
ta_criteria.each_with_object({}) do |criterion, mapping|
mapping[criterion.name.to_s.strip] = criterion
if criterion.bonus?
mapping["#{criterion.name} (#{Criterion.human_attribute_name(:bonus)})".strip] = criterion
end
end
end

def assignment_upload_criterion_columns(headers, totals, criteria_by_name)
unknown_columns = []
columns = headers.each_with_index.filter_map do |header, i|
header = header.to_s.strip
criterion = criteria_by_name[header]
next [i, criterion] unless criterion.nil?

if uploaded_assignment_mark_column?(header, totals[i])
unknown_columns << header
end
nil
end

if columns.empty? || unknown_columns.present?
raise CsvInvalidLineError,
I18n.t('assignments.upload_grades.invalid_criteria', criteria: unknown_columns.join(', '))
end

columns
end

def uploaded_assignment_mark_column?(header, total)
return false if header.blank?
return false if header == I18n.t('results.total_mark') || header == 'Bonus/Deductions'

total_value = Float(total, exception: false)
!total_value.nil? && total_value >= 0
end

def result_for_uploaded_marks_row(row, group_name_index, user_name_index,
results_by_group_name, results_by_user_name, results_by_id)
group_name = row[group_name_index]
user_name = row[user_name_index]
return results_by_group_name[group_name] if group_name.present? && results_by_group_name.key?(group_name)

result_id = results_by_user_name[user_name]
results_by_id[result_id]
end

def parse_uploaded_mark(value)
return if value.blank?

mark = Float(value, exception: false)
raise CsvInvalidLineError if mark.nil? || mark.negative?

mark
end
private :criteria_by_upload_name,
:assignment_upload_criterion_columns,
:uploaded_assignment_mark_column?,
:result_for_uploaded_marks_row,
:parse_uploaded_mark

# Returns all the submissions that have not been graded (completed).
# Note: This assumes that every submission has at least one result.
def ungraded_submission_results
Expand Down
1 change: 1 addition & 0 deletions app/policies/assignment_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
class AssignmentPolicy < ApplicationPolicy
default_rule :manage?
alias_rule :summary?, to: :view?
alias_rule :upload_grades?, to: :manage?
alias_rule :stop_batch_tests?, :batch_runs?, to: :manage_tests?
alias_rule :show?, :peer_review?, to: :student?
alias_rule :starter_file?, :download_starter_file_mappings?, :download_sample_starter_files?,
Expand Down
3 changes: 2 additions & 1 deletion app/views/assignments/summary.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
{
course_id: <%= @current_course.id %>,
assessment_id: <%= @assignment.id %>,
is_instructor: <%= @current_role.instructor? %>
is_instructor: <%= @current_role.instructor? %>,
encodings: <%= raw @encodings.to_json %>
}
);
});
Expand Down
3 changes: 3 additions & 0 deletions config/locales/views/assignments/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,6 @@ en:
upload_config_help_html: "<p>This zip file will configure and create this assignment.</p><p>This does not configure student or grader settings.<br>For more information please visit <a href=https://github.com/MarkUsProject/Wiki/blob/%{markus_version}/Instructor-Guide--Importing-and-Exporting-Data.md#%{section_id}>this page</a>.</p>"
upload_file_requirement: Filename %{file_name} is not allowed for this assignment.
upload_file_requirement_in_folder: Filename %{file_name} in %{file_path} is not allowed for this assignment.
upload_grades:
invalid_criteria: 'No matching criteria were found in the uploaded CSV. Unknown criteria: %{criteria}'
overwrite: Overwrite existing grades?
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,7 @@
get 'peer_review'
get 'populate_starter_file_manager'
get 'summary'
post 'upload_grades'
get 'batch_runs'
post 'set_boolean_graders_options'
get 'stop_test'
Expand Down
Loading
Loading