diff --git a/app/assets/javascripts/annotations_helpers.js b/app/assets/javascripts/annotations_helpers.js
new file mode 100644
index 000000000..c782543e9
--- /dev/null
+++ b/app/assets/javascripts/annotations_helpers.js
@@ -0,0 +1,142 @@
+/* following paths/functions for annotations */
+var sharedCommentsPath = basePath + "/shared_comments";
+
+var createPath = basePath + ".json";
+var updatePath = function (ann) {
+ return [basePath, "/", ann.id, ".json"].join("");
+};
+var deletePath = updatePath;
+
+function getSharedCommentsForProblem(problem_id) {
+ return localCache['shared_comments'][problem_id]?.map(
+ (annotation) => {
+ return {label: annotation.comment ?? annotation, value: annotation}
+ }
+ )
+}
+
+var selectAnnotation = box => (e, ui) => {
+ const {value} = ui.item;
+
+ const score = value.value ?? 0;
+ box.find('#comment-score').val(score);
+
+ const $textarea = box.find("#comment-textarea");
+ M.textareaAutoResize($textarea);
+
+ return false;
+}
+
+function focusAnnotation( event, ui ) {
+ $(this).val(ui.item.label);
+ return false;
+}
+
+// retrieve shared comments
+// also retrieves annotation id to allow easy deletion in the future
+function retrieveSharedComments(cb) {
+ $.getJSON(sharedCommentsPath, function (data) {
+ localCache['shared_comments'] = {};
+ data.forEach(e => {
+ if (!e.problem_id)
+ return;
+ localCache['shared_comments'][e.problem_id] ||= [];
+ localCache['shared_comments'][e.problem_id].push(e);
+ });
+ cb?.();
+ });
+}
+
+function purgeCurrentPageCache() {
+ localCache[currentHeaderPos] = {
+ codeBox: `
${$('#code-box').html()}
`,
+ pdf: false,
+ symbolTree: `${$('#symbol-tree-box').html()}
`,
+ versionLinks: `${$('#version-links').html()} `,
+ versionDropdown: `${$('#version-dropdown').html()} `,
+ url: window.location.href,
+ };
+}
+
+function plusFix(n) {
+ n = parseFloat(n)
+ if (isNaN(n)) n = 0;
+
+ if (n > 0) {
+ return "+" + n.toFixed(2);
+ }
+
+ return n.toFixed(2);
+}
+
+function getProblemNameWithId(problem_id) {
+ var problem_id = parseInt(problem_id, 10);
+ var problem = _.findWhere(problems, { "id": problem_id });
+ if (problem == undefined) return "Deleted Problem(s)";
+ return problem.name;
+}
+
+
+// create an HTML element real nice and easy like
+function elt(t, a) {
+ var el = document.createElement(t);
+ if (a) {
+ for (var attr in a)
+ if (a.hasOwnProperty(attr))
+ el.setAttribute(attr, a[attr]);
+ }
+ for (var i = 2; i < arguments.length; ++i) {
+ var arg = arguments[i];
+ if (typeof arg === "string")
+ arg = document.createTextNode(arg);
+ el.appendChild(arg);
+ }
+ return el;
+}
+
+
+// this creates a JSON representation of what the actual Rails Annotation model looks like
+function createAnnotation() {
+ var annObj = {
+ submitted_by: cudEmailStr,
+ };
+ if (fileNameStr != null) {
+ annObj.filename = fileNameStr
+ }
+
+ if (currentHeaderPos || currentHeaderPos === 0) {
+ annObj.position = currentHeaderPos
+ }
+
+ return annObj;
+}
+
+function getAnnotationObject(annotationId) {
+ for (var i = 0; i < annotations.length; i++) {
+ if (annotations[i].id == annotationId) {
+ return annotations[i];
+ }
+ }
+}
+
+
+var updateAnnotationBox = function (annObj) {
+
+ var problemStr = annObj.problem_id ? getProblemNameWithId(annObj.problem_id) : "General";
+ var valueStr = annObj.value ? annObj.value.toString() : "None";
+ var commentStr = annObj.comment;
+
+ if (annotationMode === "PDF") {
+ $('#ann-box-' + annObj.id).find('.score-box').html("Problem: " + problemStr + "
Score: " + valueStr + "
");
+ $("#ann-box-" + annObj.id).find('.body').html(commentStr);
+ }
+ else {
+ $('#ann-box-' + annObj.id).find('.score-box').html("" + problemStr + " " + valueStr + " ");
+ }
+ $('#ann-box-' + annObj.id).find('.edit').show();
+ $('#ann-box-' + annObj.id).find('.body').show();
+ $('#ann-box-' + annObj.id).find('.score-box').show();
+ $('#ann-box-' + annObj.id).find('.minimize').show();
+ $('#ann-box-' + annObj.id).draggable('enable');
+ $('#ann-box-' + annObj.id).resizable('enable');
+}
diff --git a/app/assets/javascripts/annotations_popup.js b/app/assets/javascripts/annotations_popup.js
new file mode 100644
index 000000000..a3723e41f
--- /dev/null
+++ b/app/assets/javascripts/annotations_popup.js
@@ -0,0 +1,158 @@
+const updateEditTweakButtons = () => {
+ tweaks.forEach(({tweak, submission}) => {
+ get_tweak_total(submission.id).then(data => {
+ tweak?.setState({ amount: data })
+ })
+ })
+}
+const get_tweak_total = (submission_id) => {
+ return new Promise((resolve, reject) => {
+ $.ajax({
+ url: `submissions/${submission_id}/tweak_total`,
+ method: 'GET',
+ dataType: 'json',
+ success: (data) => {
+ resolve(data);
+ },
+ error: (error) => {
+ console.error("There was an error fetching the scores:", error);
+ reject(error);
+ }
+ });
+ });
+}
+function newAnnotationFormCode() {
+ var box = $(".base-annotation-line").clone();
+ box.removeClass("base-annotation-line");
+
+ box.addClass("new-annotation");
+
+ // Creates a dictionary of problem and grader_id
+ var problemGraderId = {};
+
+ _.each(scores, function (score) {
+ problemGraderId[score.problem_id] = score.grader_id;
+ });
+
+ _.each(problems, function (problem, i) {
+ if (problemGraderId[problem.id] !== 0) { // Because grader == 0 is autograder
+ box.find("select")?.append(
+ $(" ").val(problem.id).text(problem.name)
+ );
+ }
+ });
+
+ box.find('.annotation-form').show();
+ box.find('.annotation-cancel-button').click(function (e) {
+ e.preventDefault();
+ $(this).parents(".annotation-form").parent().remove();
+ $('#annotation-modal').modal('close');
+ })
+
+ box.find('#comment-textarea').autocomplete({
+ appendTo: box.find('#comment-textarea').parent(),
+ source: getSharedCommentsForProblem(box.find("select").val()) || [],
+ minLength: 0,
+ delay: 0,
+ select: selectAnnotation(box),
+ focus: focusAnnotation
+ }).focus(function () {
+ M.textareaAutoResize($(this));
+ $(this).autocomplete('search', $(this).val())
+ });
+
+ box.tooltip();
+
+ box.find("select").on('change', function () {
+ const problem_id = $(this).val();
+
+ // Update autocomplete to display shared comments for selected problem
+ box.find("#comment-textarea").autocomplete({
+ source: getSharedCommentsForProblem(problem_id) || []
+ });
+ });
+
+ 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();
+
+ if (comment === undefined || comment === "") {
+ box.find('.error').text("Annotation comment can not be blank!").show();
+ return;
+ }
+
+ if (score === undefined || score === "") {
+ box.find('.error').text("Annotation score can not be blank!").show();
+ return;
+ }
+
+ if (problem_id == undefined) {
+ if ($('.select').children('option').length > 0)
+ box.find('.error').text("Problem not selected").show();
+ else
+ box.find('.error').text("There are no non-autograded problems. Create a new one at Edit Assessment > Problems").show();
+ return;
+ }
+ submitNewAnnotation(comment, shared_comment, true, score, problem_id, 0, $(this));
+ });
+
+ return box;
+}
+
+/* sets up and calls $.ajax to submit an annotation */
+var submitNewAnnotation = function (comment, shared_comment, global_comment, value, problem_id, lineInd, form) {
+ var newAnnotation = createAnnotation();
+ Object.assign(newAnnotation, { line: parseInt(lineInd), comment, value, problem_id, filename: fileNameStr, shared_comment, global_comment });
+
+ if (comment === undefined || comment === "") {
+ $(form).find('.error').text("Could not save annotation. Please refresh the page and try again.").show();
+ return;
+ }
+
+ $(form).find('.error').hide();
+
+ $.ajax({
+ url: createPath,
+ accepts: "json",
+ dataType: "json",
+ data: {
+ annotation: newAnnotation
+ },
+ type: "POST",
+ success: function (data, type) {
+ updateEditTweakButtons();
+ $(form).parent().remove();
+ $('#annotation-modal').modal('close');
+ },
+ error: function (result, type) {
+ $(form).find('.error').text("Could not save annotation. Please refresh the page and try again.").show();
+ },
+ complete: function (result, type) { }
+ });
+
+}
+
+var updateAnnotation = function (annotationObj, box) {
+ $(box).find(".error").hide();
+ $.ajax({
+ url: updatePath(annotationObj),
+ accepts: "json",
+ dataType: "json",
+ data: {
+ annotation: annotationObj
+ },
+ type: "PUT",
+ success: function (data, type) {
+ $(box).remove();
+ displayAnnotations();
+ },
+ error: function (result, type) {
+ $(box).find('.error').text("Failed to save changes to the annotation. Please refresh the page and try again.").show();
+ },
+ complete: function (result, type) { }
+ });
+}
+
diff --git a/app/assets/javascripts/autolab_component.js b/app/assets/javascripts/autolab_component.js
new file mode 100644
index 000000000..a937ee3c4
--- /dev/null
+++ b/app/assets/javascripts/autolab_component.js
@@ -0,0 +1,45 @@
+/**
+ * Usage:
+ * // Create a new instance, associating it with the element specified by id 'app'
+ const MyComponent = new AutolabComponent('app');
+
+ // Define a template for the instance
+ MyComponent.template = function() {
+ return `
+
+
Name: ${this.state.name}
+
Age: ${this.state.age}
+
+ `;
+ };
+
+ // Set the initial state
+ MyComponent.setState({
+ name: 'John',
+ age: 30
+ });
+
+ // Later in the code, you can update the state like this:
+ // MyComponent.setState({ age: 31 });
+ */
+
+
+function AutolabComponent(elementId, initialState = {}, template = () => {}) {
+ this.elementId = elementId;
+ this.state = initialState;
+ this.template = template;
+
+ this.setState = function(newState = {}) {
+ $.extend(this.state, newState);
+ this.render();
+ };
+
+ this.template = function() {
+ // Default template; should be overridden by users of the library
+ return `
`;
+ };
+
+ this.render = function() {
+ $(`#${this.elementId}`).html(this.template());
+ };
+}
diff --git a/app/assets/javascripts/manage_submissions.js b/app/assets/javascripts/manage_submissions.js
index de37f533f..04d8d36dd 100644
--- a/app/assets/javascripts/manage_submissions.js
+++ b/app/assets/javascripts/manage_submissions.js
@@ -1,115 +1,482 @@
-var hideStudent;
+const manage_submissions_endpoints = {
+ 'regrade-selected': 'regradeBatch',
+ 'score_details': 'submissions/score_details',
+};
+const buttonIDs = ['#regrade-selected', '#delete-selected', '#download-selected', '#excuse-selected'];
+
+let tweaks = [];
+let currentPage = 0;
$(document).ready(function() {
+ var submission_info = {}
+ const EditTweakButton = (totalSum) => {
+ if (totalSum == null) {
+ return `
+ -
+ edit
+ `
+ }
+ return `
+ ${totalSum < 0 ? "" : "+"}${totalSum} points
+ `
+ }
- $.fn.dataTable.ext.search.push(
- function(settings, data, dataIndex) {
- var filterOnlyLatest = $("#only-latest").is(':checked');
- if (!filterOnlyLatest) {
- // if not filtered, return all the rows
- return true;
- } else {
- var isSubmissionLatest = data[8]; // use data for the age column
- return (isSubmissionLatest == "true");
- }
+ function get_score_details(course_user_datum_id) {
+ return new Promise((resolve, reject) => {
+ $.ajax({
+ url: manage_submissions_endpoints['score_details'],
+ type: 'GET',
+ data: { cuid: course_user_datum_id },
+ success: function (data) {
+ resolve(data);
+ },
+ error: function (err) {
+ reject(err);
+ }
+ })
+ });
+ }
+
+ const selectSubmission = (data) => {
+ submission_info = data
+ basePath = data?.base_path;
+ sharedCommentsPath = `${basePath}/shared_comments`;
+ createPath = basePath + ".json";
+ updatePath = function (ann) {
+ return [basePath, "/", ann.id, ".json"].join("");
+ };
+ scores = data?.scores;
+ deletePath = updatePath;
+ }
+
+ const selectTweak = submissions => {
+ const submissionsById = Object.fromEntries(submissions.map(sub => [sub.id, sub]))
+
+ return function () {
+ $('#annotation-modal').modal('open');
+ const $student = $(this);
+ const submission = $student.data('submissionid');
+ selectSubmission(submissionsById[submission]);
+ retrieveSharedComments(() => {
+ const newForm = newAnnotationFormCode();
+ $('#active-annotation-form').html(newForm);
+ });
}
- );
-
- var $floater = $("#floater"),
- $backdrop = $("#gradeBackdrop");
- $('.trigger').bind('ajax:success', function showStudent(event, data, status, xhr) {
- $floater.html(data);
- $floater.show();
- $backdrop.show();
- });
+ }
- /** override the global **/
- hideStudent = function hideStudent() {
- $floater.hide();
- $backdrop.hide();
- };
-
- var table = $('#submissions').DataTable({
- 'sPaginationType': 'full_numbers',
- 'iDisplayLength': 100,
- 'oLanguage': {
- 'sLengthMenu':' ' +
- 'Show only latest '
- },
- "columnDefs": [{
- "targets": [8],
- "visible": false,
- // "searchable": false
- }],
- "aaSorting": [
- [4, "desc"]
- ]
- });
+ $(document).ready(function () {
+ $('.modal').modal();
- $("#only-latest").on("change", function() {
- table.draw();
- });
+ $('.score-details').on('click', function () {
+ // Get the email
+ const course_user_datum_id = $(this).data('cuid');
+ const email = $(this).data('email');
- var ids = [];
- $("input[type='checkbox']:checked").each(function() {
- ids.push($(this).val());
- });
+ // Set the email
+ $('#score-details-email').html(email);
+
+ // Clear the modal content
+ $('#score-details-content').html('');
+
+ // Add a loading bar
+ $('#score-details-content').html(`
+ `);
+
+ // Open the modal
+ $('#score-details-modal').modal('open');
+
+ // Fetch data and render it in the modal
+ get_score_details(course_user_datum_id).then((data) => {
+ const problem_headers = data.submissions[0].problems.map((problem) => {
+ const max_score = problem.max_score;
+ const autograded = problem.grader_id == null || problem.grader_id < 0 ? " (Autograded)" : "";
+ return `
+ ${problem.name}
+
+ ${max_score} ${autograded}
+ `;
+ }).join('');
+
+ tweaks = [];
+
+ const submissions_body = data.submissions.map((submission) => {
+ const Tweak = new AutolabComponent(`tweak-value-${submission.id}`, { amount: null });
+ Tweak.template = function () {
+ return EditTweakButton( this.state.amount );
+ }
+ tweaks.push({tweak: Tweak, submission_id: submission.id, submission});
- var selectedSubmissions = [];
+ let tweak_value = data?.tweaks[submission.id]?.value ?? "None";
+ if (tweak_value != "None" && tweak_value > 0) {
+ tweak_value = `+${tweak_value}`;
+ }
- var initialBatchUrl = $("#batch-regrade").prop("href");
+ // Convert to human readable date with timezone
+ const human_readable_created_at =
+ moment(submission.created_at).format('MMM Do YY, h:mma z UTC Z');
- function updateBatchRegradeButton() {
+ const view_button = submission.filename ?
+ ``
+ : "None";
- if (selectedSubmissions.length == 0) {
- $("#batch-regrade").fadeOut(120);
+ const download_button =
+ /text/.test(submission.detected_mime_type) ?
+ `` :
+ ``;
+ return `
+
+
+ ${submission.version}
+
+
+ ${human_readable_created_at}
+
+
+ ${submission.total}
+
+ ${submission.problems.
+ map((problem) =>
+ `${data.scores[submission.id]?.[problem.id]?.['score'] ?? "-"} `
+ ).join('')}
+
+ ${submission.late_penalty}
+
+
+
+
+
+
+ ${view_button}
+ ${download_button}
+
+ `;
+ }).join('');
+
+ const submissions_table =
+ ` Click on non-autograded problem scores to edit or leave a comment.
+
+
+
+ Version No.
+ Submission Date
+ Final Score
+ ${problem_headers}
+ Late Penalty
+ Tweak
+ Actions
+
+
+
+ ${submissions_body}
+
+
+ `;
+
+ $('#score-details-content').html(` ${submissions_table}
`);
+
+ updateEditTweakButtons();
+ $('#score-details-table').DataTable({
+ "order": [[0, "desc"]],
+ "paging": false,
+ "info": false,
+ "searching": false,});
+
+ return data.submissions;
+
+ }).then((submissions) => {
+ $('.tweak-button').on('click', selectTweak(submissions));
+ }).catch((err) => {
+ $('#score-details-content').html(`
+ `);
+ });
+ });
+
+ var selectedStudentCids = [];
+ var selectedSubmissions = [];
+
+ var table = $('#submissions').DataTable({
+ 'dom': 'f<"selected-buttons">rtip', // show buttons, search, table
+ 'paging': true,
+ 'createdRow': completeRow,
+ 'sPaginationType': 'full_numbers',
+ 'pageLength': 100,
+ 'info': true,
+ 'deferRender': true,
+ });
+
+ // Check if the table is empty
+ if (table.data().count() === 0) {
+ $('#submissions').closest('.dataTables_wrapper').hide(); // Hide the table and its controls
+ $('#no-data-message').show(); // Optionally show a custom message
} else {
- $("#batch-regrade").fadeIn(120);
+ $('#no-data-message').hide(); // Hide custom message when there is data
}
- var urlParam = $.param({
- "submission_ids": selectedSubmissions
+
+ function completeRow(row, data, index) {
+ var submission = additional_data[index];
+ $(row).attr('data-submission-id', submission['submission-id']);
+ }
+
+ $('thead').on('click', function(e) {
+ if (currentPage < 0) {
+ currentPage = 0
+ }
+ if (currentPage > table.page.info().pages) {
+ currentPage = table.page.info().pages - 1
+ }
+ table.page(currentPage).draw(false);
+ })
+
+ // Listen for select-all checkbox click
+ $('#cbox-select-all').on('click', async function(e) {
+ var selectAll = $(this).is(':checked');
+ await toggleAllRows(selectAll);
});
- var newHref = initialBatchUrl + "?" + urlParam;
- $("#batch-regrade").html("Regrade " + selectedSubmissions.length + " Submissions")
- $("#batch-regrade").prop("href", newHref);
- };
-
- function toggleRow(submissionId) {
- if (selectedSubmissions.indexOf(submissionId) < 0) {
- // not in the list
- selectedSubmissions.push(submissionId);
- $("#cbox-" + submissionId).prop('checked', true);
- $("#row-" + submissionId).addClass("selected");
- } else {
- // in the list
- $("#cbox-" + submissionId).prop('checked', false);
- $("#row-" + submissionId).removeClass("selected");
- selectedSubmissions = _.without(selectedSubmissions, submissionId);
+
+ // Function to toggle all checkboxes
+ function toggleAllRows(selectAll) {
+ $('#submissions tbody .cbox').each(function() {
+ $('#cbox-select-all').prop('checked', selectAll);
+ var submissionId = parseInt($(this).attr('id').replace('cbox-', ''), 10);
+ if (selectAll) {
+ if (selectedSubmissions.indexOf(submissionId) === -1) {
+ toggleRow(submissionId, true); // force select
+ }
+ } else {
+ if (selectedSubmissions.indexOf(submissionId) !== -1) {
+ toggleRow(submissionId, false); // force unselect
+ }
+ }
+ });
+ changeButtonStates(!selectedSubmissions.length); // update button states
}
- updateBatchRegradeButton();
- }
- $("#submissions").on("click", ".exclude-click i", function (e) {
- e.stopPropagation();
- return;
- });
+ // SELECTED BUTTONS
- $('#submissions').on("click", ".submission-row", function(e) {
- // Don't toggle row if we originally clicked on an anchor and input tag
- if(e.target.localName != 'a' && e.target.localName !='input') {
- // e.target: tightest element that triggered the event
- // e.currentTarget: element the event has bubbled up to currently
- var submissionId = parseInt(e.currentTarget.id.replace("row-", ""), 10);
- toggleRow(submissionId);
- return false;
+ // create selected buttons inside datatable wrapper
+ var regradeHTML = $('#regrade-batch-html').html();
+ var deleteHTML = $('#delete-batch-html').html();
+ var downloadHTML = $('#download-batch-html').html();
+ var excuseHTML = $('#excuse-batch-html').html();
+ $('div.selected-buttons').html(`${regradeHTML}${deleteHTML}${downloadHTML}${excuseHTML}
`);
+
+ // add ids to each selected button
+ $('#selected-buttons > a').each(function () {
+ let idText = this.title.split(' ')[0].toLowerCase() + '-selected';
+ this.setAttribute('id', idText);
+ });
+
+ if (!is_autograded) {
+ $('#regrade-selected').hide();
}
- });
- $('#submissions').on("click", ".cbox", function(e) {
- var submissionId = parseInt(e.currentTarget.id.replace("cbox-", ""), 10);
- toggleRow(submissionId);
- e.stopPropagation();
+ // base URLs for selected buttons
+ var baseURLs = {};
+ buttonIDs.forEach(function(id) {
+ baseURLs[id] = $(id).prop('href');
+ });
+
+ function changeButtonStates(state) {
+ state ? buttonIDs.forEach((id) => $(id).addClass('disabled')) : buttonIDs.forEach((id) => $(id).removeClass('disabled'));
+
+ // prop each selected button with selected submissions
+ if (!state) {
+ var urlParam = $.param({'submission_ids': selectedSubmissions});
+ buttonIDs.forEach(function(id) {
+ var newHref = baseURLs[id] + '?' + urlParam;
+ $(id).prop('href', newHref);
+ });
+ } else {
+ buttonIDs.forEach(function(id) {
+ $(id).prop('href', baseURLs[id]);
+ });
+ }
+ }
+
+ changeButtonStates(true); // disable all buttons by default
+
+ // SELECTING STUDENT CHECKBOXES
+ function toggleRow(submissionId, forceSelect = null) {
+ var selectedCid = submissions_to_cud[submissionId];
+ const isSelected = selectedSubmissions.includes(submissionId);
+ const shouldSelect = forceSelect !== null ? forceSelect : !isSelected;
+
+ if (shouldSelect && !isSelected) {
+ // not in the list
+ selectedSubmissions.push(submissionId);
+ $('#cbox-' + submissionId).prop('checked', true);
+ $('#row-' + submissionId).addClass('selected');
+ // add student cid
+ if (selectedStudentCids.indexOf(selectedCid) < 0) {
+ selectedStudentCids.push(selectedCid);
+ }
+ } else if (!shouldSelect && isSelected) {
+ // in the list
+ $('#cbox-' + submissionId).prop('checked', false);
+ $('#row-' + submissionId).removeClass('selected');
+ selectedSubmissions = _.without(selectedSubmissions, submissionId);
+ // remove student cid, but only if none of their submissions are selected
+ const hasOtherSelectedSubmissions = selectedSubmissions.some(id => submissions_to_cud[id] === selectedCid);
+ if (!hasOtherSelectedSubmissions) {
+ selectedStudentCids = selectedStudentCids.filter(cid => cid !== selectedCid);
+ }
+ selectedStudentCids = _.without(selectedStudentCids, selectedCid);
+ }
+ let disableButtons = !selectedSubmissions.length || (selectedSubmissions.length === 1 && selectedSubmissions[0] === 'select-all')
+ // Ensure `selectedSubmissions` contains only numbers
+ const numericSelectedSubmissions = selectedSubmissions.filter(submissionId => typeof submissionId === 'number');
+ // Update the "Select All" checkbox based on filtered numeric submissions
+ $('#cbox-select-all').prop('checked', numericSelectedSubmissions.length === $('#submissions tbody .cbox').length);
+ changeButtonStates(disableButtons);
+ }
+
+ $('#submissions').on('click', '.exclude-click i', function (e) {
+ e.stopPropagation();
+ return;
+ });
+
+ $('#submissions').on('click', '.submission-row', function (e) {
+ // Don't toggle row if we originally clicked on an icon or anchor or input tag
+ if(e.target.localName != 'i' && e.target.localName != 'a' && e.target.localName != 'input') {
+ // e.target: tightest element that triggered the event
+ // e.currentTarget: element the event has bubbled up to currently
+ var submissionId = parseInt(e.currentTarget.id.replace('row-', ''), 10);
+ toggleRow(submissionId);
+ return false;
+ }
+ });
+
+ $('#submissions_paginate').on('click', function(e) {
+ currentPage = table.page();
+ // Toggle previously selected submissions to be unselected
+ selectedSubmissions.map(selectedSubmission => toggleRow(selectedSubmission, false));
+ })
+
+ $('#submissions').on('click', '.cbox', function (e) {
+ var clickedSubmissionId = e.currentTarget.id.replace('cbox-', '');
+ var submissionId = clickedSubmissionId == 'select-all' ? clickedSubmissionId : parseInt(clickedSubmissionId, 10);
+ toggleRow(submissionId);
+ e.stopPropagation();
+ });
+
+ $.fn.dataTable.ext.search.push(
+ function(settings, data, dataIndex) {
+ var filterOnlyLatest = $("#only-latest").is(':checked');
+ if (!filterOnlyLatest) {
+ // if not filtered, return all the rows
+ return true;
+ } else {
+ var isSubmissionLatest = data[8]; // use data for the age column
+ return (isSubmissionLatest == "true");
+ }
+ }
+ );
});
+ jQuery(function() {
+ var current_popover = undefined;
+
+ function close_current_popover() {
+ current_popover.hide();
+ current_popover = undefined;
+ }
+
+ function close_current_popover_on_blur(event) {
+ if (current_popover && !jQuery(event.target).closest(current_popover).length) {
+ close_current_popover();
+ }
+ }
+
+ jQuery(document).click(function(event) {
+ event.stopPropagation();
+ close_current_popover_on_blur(event);
+ });
+
+ jQuery(document).on('click', '.excuse-popover-cancel', function(event) {
+ event.stopPropagation();
+ close_current_popover();
+ })
+
+ function show_popover(popover, at, arrow_at) {
+ if (current_popover) close_current_popover();
+
+ popover.show();
+ popover.position(at);
+
+ var arrow = jQuery(".excused-arrow", popover)
+ if (arrow_at) {
+ arrow.position(arrow_at);
+ } else {
+ arrow.position({
+ my: "right",
+ at: "left",
+ of: popover
+ });
+ }
+
+ current_popover = popover;
+ }
+
+ jQuery('#submissions').on('click', 'td.submissions-td div.submissions-name a.submissions-excused-label',
+ function(e) {
+ if (current_popover) {
+ close_current_popover();
+ return;
+ }
+
+ var link = jQuery(this);
+ let currentPopover = link.siblings("div.excused-popover");
+ currentPopover.show();
+
+ show_popover(currentPopover, {
+ my: "left center",
+ at: "right center",
+ of: link,
+ offset: "10px 0"
+ });
+ jQuery.ajax("excuse_popover", {
+ data: { submission_id: link.closest('tr').data("submission-id") },
+ success: function(data, status, jqXHR) {
+ currentPopover.html(data)
+ show_popover(currentPopover, {
+ my: "left center",
+ at: "right center",
+ of: link,
+ offset: "10px 0"
+ });
+ }
+ });
+ }
+ );
+ })
});
diff --git a/app/assets/stylesheets/_variables.scss b/app/assets/stylesheets/_variables.scss
index be7e1c944..bf00289a1 100644
--- a/app/assets/stylesheets/_variables.scss
+++ b/app/assets/stylesheets/_variables.scss
@@ -11,6 +11,16 @@ $autolab-blue-text: #0869af;
$autolab-green: #3a862d;
$autolab-light-green: #ebffd2;
$autolab-white: #fff;
+$autolab-medium-gray: #6f6f6f;
+$autolab-submissions-background: #F2F2F2;
+$autolab-excused-popover-background: #00000040;
+$autolab-black: #000000;
+$autolab-light-grey: #ebebeb;
+$autolab-grey: #A1A0A3;
+$autolab-dark-grey: #3D3E3D;
+$autolab-label: rgba(140, 0, 0, 0.95);
+$autolab-border: 1px solid #ddd;
+$autolab-sky-blue: #dceaf5;
$autolab-selected-gray: #f5f5f5;
$autolab-border-gray: #f4f1f1;
$autolab-gray-text: #676464;
diff --git a/app/assets/stylesheets/annotations.scss b/app/assets/stylesheets/annotations.scss
index 2f4a94d8e..18e886124 100755
--- a/app/assets/stylesheets/annotations.scss
+++ b/app/assets/stylesheets/annotations.scss
@@ -4,7 +4,6 @@
.page-wrapper {
height: 100vh;
- overflow: hidden;
}
#speedgrader {
diff --git a/app/assets/stylesheets/assessments.scss b/app/assets/stylesheets/assessments.scss
index 8aade5a6f..f56a0be06 100644
--- a/app/assets/stylesheets/assessments.scss
+++ b/app/assets/stylesheets/assessments.scss
@@ -1,5 +1,11 @@
@import 'variables';
+.badge {
+ &.blue {
+ margin-left: 0 !important;
+ }
+}
+
.card-title {
margin-bottom: 0 !important;
}
diff --git a/app/assets/stylesheets/assessments/quiz.css.scss b/app/assets/stylesheets/assessments/quiz.css.scss
index fc30fd948..8786149e8 100644
--- a/app/assets/stylesheets/assessments/quiz.css.scss
+++ b/app/assets/stylesheets/assessments/quiz.css.scss
@@ -15,7 +15,7 @@
}
div.block {
- background-color: #EBEBEB;
+ background-color: $autolab-light-grey;
}
div.questionBlock, div.block {
diff --git a/app/assets/stylesheets/datatable.adapter.css b/app/assets/stylesheets/datatable.adapter.css
index c23ca3f4d..5334573e3 100755
--- a/app/assets/stylesheets/datatable.adapter.css
+++ b/app/assets/stylesheets/datatable.adapter.css
@@ -1,29 +1,14 @@
-div.dataTables_length {
- float: left;
-}
-
div.dataTables_filter {
- float: right;
-}
-
-div.dataTables_info {
float: left;
}
-div.dataTables_paginate {
- float: right;
-}
-
-div.dataTables_length,
-div.dataTables_filter,
-div.dataTables_info,
-div.dataTables_paginate {
+div.dataTables_filter {
padding: 6px 0px;
+ margin-right: 20px;
}
-div.dataTables_filter,
-div.dataTables_paginate {
- padding-right: 14px;
+div.dataTables_filter input {
+ font-family: "Source Sans Pro", sans-serif;
}
div.dataTables_wrapper:after {
@@ -53,18 +38,39 @@ a.paginate_active {
padding: 2px 4px;
margin-left: 2px;
cursor: pointer;
- *cursor: hand;
+ cursor: hand;
+}
+th {
+ z-index: 1;
+ position: sticky;
+ top: 0;
}
-a.paginate_active {
- border: 1px solid #888;
+.dt-buttons {
+ height: 75px;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
}
-a.paginate_button_disabled {
- visibility: hidden;
+.dt-button {
+ margin-right: 10px !important;
+}
+
+.dt-button span {
+ display: flex;
+}
+
+.dt-button i {
+ margin: 0 6px 0 4px;
}
-div.dataTables_paginate span>a {
- width: 15px;
- text-align: center;
+div.dataTables_paginate {
+ float: right;
+ padding: 6px 0;
+ margin-right: 20px;
+}
+
+div.dataTables_info {
+ float: left;
}
diff --git a/app/assets/stylesheets/gradesheet.css.scss b/app/assets/stylesheets/gradesheet.css.scss
index 00b0cc52d..4236243ce 100755
--- a/app/assets/stylesheets/gradesheet.css.scss
+++ b/app/assets/stylesheets/gradesheet.css.scss
@@ -102,7 +102,7 @@ td.focus {
}
#grades th {
- background-color: #ebebeb;
+ background-color: $autolab-light-grey;
padding: 8px;
border-right: none;
border-left: none;
@@ -181,7 +181,7 @@ td.id {
border-radius: 5px;
z-index: 100000;
padding: 3px;
- background-color: #ebebeb;
+ background-color: $autolab-light-grey;
display: none;
}
diff --git a/app/assets/stylesheets/instructor_gradebook.scss b/app/assets/stylesheets/instructor_gradebook.scss
index 94a5aaee5..2ab0d4f83 100755
--- a/app/assets/stylesheets/instructor_gradebook.scss
+++ b/app/assets/stylesheets/instructor_gradebook.scss
@@ -133,7 +133,7 @@ div#footer {
}
#gradebook .slick-header-columns {
- background-color: #ebebeb;
+ background-color: $autolab-light-grey;
box-shadow: 0 4px 2px -2px gray;
}
diff --git a/app/assets/stylesheets/manage_submissions.css.scss b/app/assets/stylesheets/manage_submissions.css.scss
new file mode 100644
index 000000000..b6320862c
--- /dev/null
+++ b/app/assets/stylesheets/manage_submissions.css.scss
@@ -0,0 +1,76 @@
+@import 'variables';
+
+#modal-close {
+ font-size: 2rem;
+ position: absolute;
+ top: -35%;
+ right: 0;
+ padding: 0;
+}
+
+.modal-header {
+ float: right;
+ margin-bottom: 0;
+ padding-bottom: 0;
+}
+
+.score-details {
+ cursor: pointer;
+}
+
+#score-details-modal{
+ width: 90%;
+ height: auto;
+}
+
+/* Table Header */
+
+#score-details-header {
+ position: relative;
+ margin-top: 0;
+}
+
+.score-styling {
+ font-weight: normal;
+ font-style: italic;
+ text-transform: none;
+}
+
+.sorting-th {
+ display: flex;
+ align-items: center;
+}
+
+/* Table Styling */
+
+.submissions-problem-bg {
+ background-color: $autolab-submissions-background !important;
+}
+
+table.prettyBorder tr:hover {
+ background-color: white;
+}
+
+.submissions-td {
+ padding-left: 0.6rem !important;
+}
+
+.tweak-button:hover {
+ cursor: pointer;
+}
+
+.tweak-button {
+ display: flex;
+ align-content: center;
+}
+
+
+/* Icons */
+
+.material-icons {
+ margin-left: 3px;
+}
+
+.i-no-margin {
+ margin-left: 0;
+}
diff --git a/app/assets/stylesheets/style.css.scss b/app/assets/stylesheets/style.css.scss
index 021b7fef5..0a0036ee3 100644
--- a/app/assets/stylesheets/style.css.scss
+++ b/app/assets/stylesheets/style.css.scss
@@ -686,6 +686,10 @@ ul.moss-inner-list > li > input[type="checkbox"]:checked ~ div {
text-decoration: underline;
}
+div.field_with_errors {
+ display: inline;
+}
+
.table-info {
margin-bottom: auto;
display: flex;
@@ -725,7 +729,6 @@ ul.moss-inner-list > li > input[type="checkbox"]:checked ~ div {
}
/* TABLE STYLES. Styles for the multiple tables that we have for some reason */
-/* Pretty Border Tables. I vote we make this the generic table style. */
table.prettyBorder,
table.prettyBorder tr,
table.prettyBorder th,
@@ -744,7 +747,7 @@ table.prettyBorder {
background-color: #fff;
border: 1px solid #d0d0d0;
&:hover {
- background-color: #abcdef;
+ background-color: $autolab-sky-blue;
}
}
@@ -764,8 +767,8 @@ table.prettyBorder {
top: 0;
}
th {
- background-color: #ebebeb;
- color: #909090;
+ background-color: $autolab-light-grey;
+ color: $autolab-black;
cursor: pointer;
font-family: Source Sans Pro, sans-serif;
font-size: 0.8em;
@@ -775,11 +778,62 @@ table.prettyBorder {
text-transform: uppercase;
}
+ .submissions-th {
+ padding: 25px 0 25px 0;
+ font-size: 0.9rem;
+ div {
+ display: flex;
+ align-items: center;
+ }
+ p {
+ margin: 0;
+ float: left;
+ padding-right: 3px;
+ }
+ }
+
+ .sorting_desc {
+ .sort-icon__both, .sort-icon__up {
+ display: none;
+ }
+ .sort-icon__down {
+ display: inline;
+ }
+ }
+ .sorting_asc {
+ .sort-icon__both, .sort-icon__down {
+ display: none;
+ }
+ .sort-icon__up {
+ display: inline;
+ }
+ }
+
+ .sort-icon__up, .sort-icon__down {
+ display: none;
+ }
+
td {
border: 1px solid #ddd;
padding: 0 5px;
}
+ .submissions-td {
+ border-style: none;
+ padding: 5px 0 5px 0;
+ }
+
+ .submissions-cbox-label {
+ display: flex;
+ justify-content: center;
+ span::before {
+ left: 6px;
+ };
+ [type="checkbox"]:checked + span:not(.lever):before {
+ left: 3px;
+ };
+ }
+
tr.selected {
background-color: $autolab-subtle-gray;
}
@@ -936,7 +990,7 @@ input[type="password"]:focus {
// To remove line for file-field
.file-field input[type="text"].validate,
-.file-field input[type="text"].validate.valid, {
+.file-field input[type="text"].validate.valid {
border-bottom: none;
box-shadow: none;
}
@@ -1068,14 +1122,14 @@ form p:last-child {
*/
.switch > label > b {
- font-size: 1.1rem;
+ font-size: 1.1rem;
color: black
}
label[for="switch"] {
- font-size: 1rem;
+ font-size: 1rem;
color: darkslategrey;
-}
+}
/**
@@ -1141,6 +1195,7 @@ label[for="switch"] {
.checkbox input[type="checkbox"]:checked + label::before {
border: 1px solid #bbb;
+
}
.new_submission {
@@ -1438,10 +1493,10 @@ table.sub td, th {
@keyframes spin {
from {
- transform:rotate(0deg);
+ transform:rotate(0deg);
}
to {
- transform:rotate(360deg);
+ transform:rotate(360deg);
}
}
.refresh-feedback.loading {
@@ -1479,8 +1534,8 @@ table.sub td, th {
border-radius: 5px;
color: $autolab-blue-text;
box-shadow: 0px -0.5px 0.5px 0px rgba(0, 0, 0, 0.15),
- 0px 0.5px 0.5px 0.5px rgba(0, 0, 0, 0.15),
- 0px 0.5px 0.5px -0.5px rgba(0, 0, 0, 0.15);
+ 0px 0.5px 0.5px 0.5px rgba(0, 0, 0, 0.15),
+ 0px 0.5px 0.5px -0.5px rgba(0, 0, 0, 0.15);
display: flex;
justify-content: center;
}
@@ -1496,3 +1551,170 @@ table.sub td, th {
.error-header {
color: $autolab-red;
}
+
+
+/* Manage Submissions */
+.btn.submissions-main {
+ margin: 0;
+ padding: 5px 10px 5px 5px;
+ min-height: 36px;
+ height: auto;
+ line-height: 1.3;
+ display: flex;
+ align-items: center;
+}
+
+.btn.submissions-main i.left {
+ margin-right: 6px;
+}
+
+.btn.submissions-selected {
+ margin: 0;
+ margin-right: 10px;
+ padding: 0px 10px 0px 5px;
+ display: flex;
+}
+
+.btn.submissions-selected i {
+ margin: 0 6px 0 4px;
+}
+
+.buttons-row {
+ display: flex;
+ flex-direction: row;
+}
+
+.buttons-spacing {
+ margin-right: 10px;
+ a {
+ overflow-y: hidden;
+ }
+}
+
+.submissions-tweak-button {
+ margin-left: 3px;
+ color: $autolab-medium-gray;
+}
+
+.submissions-tweak-points {
+ color: $autolab-blue-text;
+}
+
+.excused-popover {
+ display: none;
+ position: absolute;
+ width: 100px;
+ height: 100px;
+ background-color: gray;
+}
+
+#selected-buttons {
+ height: 75px;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+}
+
+.selected-buttons-placeholder {
+ display: none;
+}
+
+.submissions-checkbox {
+ margin-left: 35px;
+}
+
+.submissions-center-icons {
+ display: flex;
+ align-items: center;
+}
+
+.excused-popover {
+ background-color: $autolab-submissions-background;
+ border-radius: 11px;
+ width: auto;
+ height: auto;
+ z-index: 999;
+ padding: 12px 30px;
+ box-shadow: 0 2px 2px 0 $autolab-excused-popover-background;
+}
+
+.excuse-popover-content {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+}
+
+.excuse-popover-content-text {
+ padding: 0;
+ margin: 0;
+ font-size: 16px;
+ line-height: 23.88px;
+}
+
+.excuse-popover-content-header {
+ font-size: 16px;
+ font-weight: 600;
+ line-height: 25.14px;
+ letter-spacing: -0.01em;
+}
+
+.excuse-popover-buttons {
+ display: flex;
+ gap: 16px;
+ padding-top: 8px;
+}
+
+.btn.excuse-popover-cancel {
+ background-color: $autolab-submissions-background;
+ border: 0.5px solid $autolab-dark-grey;
+ color: $autolab-dark-grey;
+}
+
+.btn.excuse-popover-cancel:hover {
+ background-color: $autolab-submissions-background;
+ color: white;
+}
+
+.submissions-center-icons .btn i{
+ margin: 0;
+}
+
+.submissions-center-icons p {
+ margin: 0 0 0 10px;
+}
+
+.submissions-excused-label {
+ color: $autolab-label;
+ margin: 0 0 0 5px;
+ font-size: 12px;
+ font-weight: bold;
+ cursor: pointer;
+}
+
+.submissions-icons {
+ margin-left: 3px;
+}
+
+.submissions-name {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+
+.submissions-score-align {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ .score-num {
+ width: 40%;
+ }
+ .score-icon {
+ width: 20%;
+ }
+}
+
+.submissions-score-icon {
+ margin-left: 5px;
+ margin-top: 5px;
+ color: $autolab-grey;
+}
\ No newline at end of file
diff --git a/app/controllers/annotations_controller.rb b/app/controllers/annotations_controller.rb
index 03372fa01..44be27521 100755
--- a/app/controllers/annotations_controller.rb
+++ b/app/controllers/annotations_controller.rb
@@ -13,7 +13,12 @@ class AnnotationsController < ApplicationController
# POST /:course/annotations.json
action_auth_level :create, :course_assistant
def create
- annotation = @submission.annotations.new(annotation_params)
+ updated_params = if annotation_params[:filename].blank?
+ annotation_params.merge({ filename: @submission.handin_file_path })
+ else
+ annotation_params
+ end
+ annotation = @submission.annotations.new(updated_params)
ActiveRecord::Base.transaction do
annotation.save
@@ -65,7 +70,6 @@ def shared_comments
.joins(:submission).where(shared_comment: true)
.where("submissions.assessment_id = ?", @assessment.id)
.order(updated_at: :desc).limit(50).as_json
-
render json: result, status: :ok
end
diff --git a/app/controllers/assessments_controller.rb b/app/controllers/assessments_controller.rb
index d7ee688fc..b5f97ca5a 100755
--- a/app/controllers/assessments_controller.rb
+++ b/app/controllers/assessments_controller.rb
@@ -45,6 +45,9 @@ class AssessmentsController < ApplicationController
action_auth_level :quickGetTotal, :course_assistant
action_auth_level :statistics, :instructor
+ # Manage submissions
+ action_auth_level :excuse_popover, :course_assistant
+
# Handin
action_auth_level :handin, :student
@@ -213,9 +216,26 @@ def import_assessments
render json: import_results
end
+ def excuse_popover
+ submission_id = params[:submission_id]
+ @submission = Submission.find(submission_id)
+ if @submission.course_user_datum.course != @course
+ render plain: "Unauthorized", status: :forbidden
+ return
+ end
+ @assessment = @submission.assessment
+ @student_email = @submission.course_user_datum.user.email
+
+ render partial: "excuse_popover", locals: {
+ email: @student_email,
+ submission: @submission
+ }
+ rescue ActiveRecord::RecordNotFound
+ render plain: "Submission not found", status: :not_found
+ end
+
# import_assessment - Imports an existing assessment from local file system
action_auth_level :import_assessment, :instructor
-
def import_assessment
if params[:assessment_name].blank?
flash[:error] = "No assessment name specified."
diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb
index 8ec94ba22..64f7b0e6a 100755
--- a/app/controllers/submissions_controller.rb
+++ b/app/controllers/submissions_controller.rb
@@ -5,12 +5,14 @@
require "tempfile"
class SubmissionsController < ApplicationController
+ include ApplicationHelper
# inherited from ApplicationController
before_action :set_assessment
before_action :set_assessment_breadcrumb
before_action :set_manage_submissions_breadcrumb, except: %i[index]
before_action :set_submission, only: %i[destroy destroyConfirm download edit update
- view release_student_grade unrelease_student_grade]
+ view release_student_grade unrelease_student_grade
+ tweak_total]
before_action :get_submission_file, only: %i[download view]
action_auth_level :index, :instructor
@@ -18,6 +20,62 @@ def index
@submissions = @assessment.submissions.includes({ course_user_datum: :user })
.order("created_at DESC")
@autograded = @assessment.has_autograder?
+
+ @submissions_to_cud = {}
+ @submissions.each do |submission|
+ currSubId = submission.id
+ currCud = submission.course_user_datum_id
+ @submissions_to_cud[currSubId] = currCud
+ end
+ @submissions_to_cud = @submissions_to_cud.to_json
+ @excused_cids = []
+ excused_students = AssessmentUserDatum.where(
+ assessment_id: @assessment.id,
+ grade_type: AssessmentUserDatum::EXCUSED
+ )
+ @excused_cids = excused_students.pluck(:course_user_datum_id)
+ @problems = @assessment.problems.to_a
+ end
+
+ action_auth_level :score_details, :instructor
+ def score_details
+ cuid = params[:cuid]
+ cud = CourseUserDatum.find(cuid)
+ submissions = @assessment.submissions.where(course_user_datum_id: cuid).order("created_at DESC")
+ scores = submissions.map(&:scores).flatten
+
+ # make a dictionary that makes submission id to score data
+ submission_id_to_score_data = {}
+ scores.each do |score|
+ if submission_id_to_score_data[score.submission_id].nil?
+ submission_id_to_score_data[score.submission_id] = {}
+ end
+ submission_id_to_score_data[score.submission_id][score.problem_id] = score
+ end
+
+ tweaks = {}
+ submission_info = submissions.as_json
+ submissions.each_with_index do |submission, index|
+ tweaks[submission.id] = submission.tweak
+ submission_info[index]["base_path"] =
+ course_assessment_submission_annotations_path(@course, @assessment, submission)
+ submission_info[index]["scores"] = Score.where(submission_id: submission.id)
+ submission_info[index]["tweak_total"] =
+ submission.global_annotations.empty? ? nil : submission.global_annotations.sum(:value)
+ total = computed_score { submission.final_score(cud) }
+ submission_info[index]["total"] =
+ submission.global_annotations.empty? ? total : total + submission_info[index]["tweak_total"]
+ submission_info[index]["late_penalty"] = computed_score { submission.late_penalty(cud) }
+ end
+
+ submissions.as_json(seen_by: @cud)
+
+ render json: { submissions: submission_info,
+ scores: submission_id_to_score_data,
+ tweaks: }, status: :ok
+ rescue StandardError => e
+ render json: { error: e.message }, status: :not_found
+ nil
end
action_auth_level :new, :instructor
@@ -125,10 +183,14 @@ def update
action_auth_level :destroy, :instructor
def destroy
- if @submission.destroy
- flash[:success] = "Submission successfully destroyed"
+ if params["destroy-confirm-check"]
+ if @submission.destroy
+ flash[:success] = "Submission successfully destroyed."
+ else
+ flash[:error] = "Submission failed to be destroyed."
+ end
else
- flash[:error] = "Submission failed to be destroyed"
+ flash[:error] = "There was an error deleting the submission."
end
redirect_to(course_assessment_submissions_path(@submission.course_user_datum.course,
@submission.assessment))
@@ -136,6 +198,54 @@ def destroy
# page to show to instructor to confirm that they would like to
# remove a given submission for a student
+ action_auth_level :destroy_batch, :instructor
+ def destroy_batch
+ submission_ids = params[:submission_ids]
+ submissions = Submission.where(id: submission_ids)
+ scount = 0
+ fcount = 0
+
+ if submissions.empty? || submissions[0].nil?
+ return
+ end
+
+ submissions.each do |s|
+ if s.nil?
+ next
+ end
+
+ unless @cud.instructor || @cud.course_assistant || s.course_user_datum_id == @cud.id
+ flash[:error] =
+ "You do not have permission to delete #{s.course_user_datum.user.email}'s submission."
+ redirect_to(course_assessment_submissions_path(submissions[0].course_user_datum.course,
+ submissions[0].assessment)) && return
+ end
+ if s.destroy
+ scount += 1
+ else
+ fcount += 1
+ end
+ end
+ if fcount == 0
+ flash[:success] =
+ "#{ActionController::Base.helpers.pluralize(scount,
+ 'submission')} destroyed.
+ #{ActionController::Base.helpers.pluralize(
+ fcount, 'submission'
+ )} failed."
+ else
+ flash[:error] =
+ "#{ActionController::Base.helpers.pluralize(scount,
+ 'submission')} destroyed.
+ #{ActionController::Base.helpers.pluralize(
+ fcount, 'submission'
+ )} failed."
+ end
+ redirect_to(course_assessment_submissions_path(submissions[0].course_user_datum.course,
+ submissions[0].assessment)) && return
+ end
+
+ # this is good
action_auth_level :destroyConfirm, :instructor
def destroyConfirm; end
@@ -163,13 +273,9 @@ def missing
end
# should be okay, but untested
- action_auth_level :downloadAll, :course_assistant
- def downloadAll
- failure_redirect_path = if @cud.course_assistant
- course_assessment_path(@course, @assessment)
- else
- course_assessment_submissions_path(@course, @assessment)
- end
+ action_auth_level :download_all, :course_assistant
+ def download_all
+ flash[:error] = "Cannot index submissions for nil assessment" if @assessment.nil?
unless @assessment.valid?
flash[:error] = "The assessment has errors which must be rectified."
@@ -182,7 +288,12 @@ def downloadAll
if @assessment.disable_handins
flash[:error] = "There are no submissions to download."
- redirect_to failure_redirect_path and return
+ if @cud.course_assistant
+ redirect_to course_assessment_path(@course, @assessment)
+ else
+ redirect_to course_assessment_submissions_path(@course, @assessment)
+ end
+ return
end
submissions = if params[:final]
@@ -191,6 +302,10 @@ def downloadAll
@assessment.submissions.includes(:course_user_datum)
end
+ if submissions.empty?
+ return
+ end
+
submissions = submissions.select do |s|
p = s.handin_file_path
@cud.can_administer?(s.course_user_datum) && !p.nil? && File.exist?(p) && File.readable?(p)
@@ -205,7 +320,53 @@ def downloadAll
if result.nil?
flash[:error] = "There are no submissions to download."
- redirect_to failure_redirect_path and return
+ redirect_to appropriate_redirect_path
+ return
+ end
+
+ send_data(result.read, # to read from stringIO object returned by create_zip
+ type: "application/zip",
+ disposition: "attachment", # tell browser to download
+ filename: "#{@course.name}_#{@course.semester}_#{@assessment.name}_submissions.zip")
+ end
+
+ action_auth_level :download_batch, :course_assistant
+ def download_batch
+ submission_ids = params[:submission_ids]
+ flash[:error] = "Cannot index submissions for nil assessment" if @assessment.nil?
+
+ unless @assessment.valid?
+ @assessment.errors.full_messages.each do |msg|
+ flash[:error] += " #{msg}"
+ end
+ flash[:html_safe] = true
+ end
+
+ submissions = @assessment.submissions.where(id: submission_ids).select do |submission|
+ @cud.can_administer?(submission.course_user_datum)
+ end
+
+ if submissions.empty? || submissions[0].nil?
+ return
+ end
+
+ filedata = submissions.collect do |s|
+ unless @cud.instructor || @cud.course_assistant || s.course_user_datum_id == @cud.id
+ flash[:error] =
+ "You do not have permission to download #{s.course_user_datum.user.email}'s submission."
+ redirect_to(course_assessment_submissions_path(submissions[0].course_user_datum.course,
+ submissions[0].assessment)) && return
+ end
+ p = s.handin_file_path
+ email = s.course_user_datum.user.email
+ [p, download_filename(p, email)] if !p.nil? && File.exist?(p) && File.readable?(p)
+ end.compact
+
+ result = Archive.create_zip filedata # result is stringIO to be sent
+ if result.nil?
+ flash[:error] = "There are no submissions to download."
+ redirect_to appropriate_redirect_path
+ return
end
send_data(result.read, # to read from stringIO object returned by create_zip
@@ -214,6 +375,96 @@ def downloadAll
filename: "#{@course.name}_#{@course.semester}_#{@assessment.name}_submissions.zip")
end
+ action_auth_level :submission_info, :instructor
+ def tweak_total
+ tweak =
+ @submission.global_annotations.empty? ? nil : @submission.global_annotations.sum(:value)
+ render json: tweak
+ end
+
+ action_auth_level :excuse_batch, :course_assistant
+ def excuse_batch
+ submission_ids = params[:submission_ids]
+ flash[:error] = "Cannot index submissions for nil assessment" if @assessment.nil?
+
+ unless @assessment.valid?
+ @assessment.errors.full_messages.each do |msg|
+ flash[:error] += " #{msg}"
+ end
+ flash[:html_safe] = true
+ end
+
+ submissions = submission_ids.map { |sid| @assessment.submissions.find_by(id: sid) }
+
+ if submissions.empty? || submissions[0].nil?
+ flash[:error] = "No students selected."
+ redirect_to course_assessment_submissions_path(@course, @assessment)
+ return
+ end
+
+ auds_to_excuse = []
+ submissions.each do |submission|
+ next if submission.nil?
+
+ aud = AssessmentUserDatum.find_by(
+ assessment_id: @assessment.id,
+ course_user_datum_id: submission.course_user_datum_id
+ )
+
+ if !aud.nil? && aud.grade_type != AssessmentUserDatum::EXCUSED
+ auds_to_excuse << aud
+ end
+ end
+
+ if auds_to_excuse.empty?
+ flash[:error] = "No students to excuse."
+ redirect_to course_assessment_submissions_path(@course, @assessment)
+ return
+ end
+
+ auds_to_excuse.each do |aud|
+ next if aud.update(grade_type: AssessmentUserDatum::EXCUSED)
+
+ student_email = aud.course_user_datum.user.email
+ student_name = aud.course_user_datum.user.name
+ flash[:error] ||= ""
+ flash[:error] += "Could not excuse student #{student_name} (#{student_email}): "\
+ "#{aud.errors.full_messages.join(', ')}"
+ end
+
+ flash[:success] =
+ "#{ActionController::Base.helpers.pluralize(auds_to_excuse.size, 'student')} excused."
+ redirect_to course_assessment_submissions_path(@course, @assessment)
+ end
+
+ action_auth_level :unexcuse, :course_assistant
+ def unexcuse
+ submission_id = params[:submission]
+ flash[:error] = "Cannot index submission for nil assessment" if @assessment.nil?
+
+ unless @assessment.valid?
+ @assessment.errors.full_messages.each do |msg|
+ flash[:error] += " #{msg}"
+ end
+ flash[:html_safe] = true
+ end
+
+ submission = @assessment.submissions.find_by(id: submission_id)
+
+ unless submission.nil?
+ aud = AssessmentUserDatum.find_by(
+ assessment_id: @assessment.id,
+ course_user_datum_id: submission.course_user_datum_id
+ )
+ if !aud.nil? && !aud.update(grade_type: AssessmentUserDatum::NORMAL)
+ flash[:error] = "Could not un-excuse student."
+ end
+ end
+
+ flash[:success] = "#{aud.course_user_datum.user.email} has been unexcused."
+ redirect_to course_assessment_submissions_path(@course, @assessment)
+ end
+
# Action to be taken when the user wants do download a submission but
# not actually view it. If the :header_position parameter is set, it will
# try to send the file at that position in the archive.
@@ -233,7 +484,7 @@ def download
elsif params[:annotated]
# Only show annotations if grades have been released or the user is an instructor
@annotations = []
- if @submission.grades_released?(@cud)
+ if @submission.grades_released?(@cud) || @cud.instructor || @cud.course_assistant
@annotations = @submission.annotations.to_a
end
@@ -453,7 +704,7 @@ def view
@annotations.sort! { |a, b| a.line.to_i <=> b.line.to_i }
# Only show annotations if grades have been released or the user is an instructor
- unless @submission.grades_released?(@cud)
+ unless @submission.grades_released?(@cud) || @cud.instructor || @cud.course_assistant
@annotations = []
end
@@ -619,6 +870,19 @@ def unrelease_student_grade
private
+ def appropriate_redirect_path
+ if @cud.course_assistant
+ course_assessment_path(@course, @assessment)
+ else
+ course_assessment_submissions_path(@course, @assessment)
+ end
+ end
+
+ def new_submission_params
+ params.require(:submission).permit(:course_used_datum_id, :notes, :file,
+ tweak_attributes: %i[_destroy kind value])
+ end
+
def edit_submission_params
params.require(:submission).permit(:notes,
tweak_attributes: %i[_destroy kind value])
diff --git a/app/form_builders/form_builder_with_date_time_input.rb b/app/form_builders/form_builder_with_date_time_input.rb
index 3f78a21e2..2375263bf 100755
--- a/app/form_builders/form_builder_with_date_time_input.rb
+++ b/app/form_builders/form_builder_with_date_time_input.rb
@@ -1,172 +1,172 @@
-# Extend the Rails FormBuilder class.
-#
-# The naming is unfortunate, as this FormBuilder does more than just add a
-# custom datetimepicker. In reality, it's goal is to wrap common form builder
-# methods in Bootstrap boilerplate code.
-class FormBuilderWithDateTimeInput < ActionView::Helpers::FormBuilder
- %w(text_field text_area email_field number_field file_field).each do |method_name|
- # retain access to default textfield, etc. helpers
- alias_method "vanilla_#{method_name}", method_name
-
- define_method(method_name) do |name, *args|
- options = args.extract_options!
-
- # DEPRECATED: add form-control class (for Bootstrap styling) and pass on to Rails
- options[:class] = (options[:class]).to_s
-
- unless options.include?(:placeholder)
- options[:placeholder] = ""
- end
- field = super name, *(args + [options])
-
- wrap_field name, field, options
- end
- end
-
- def score_adjustment_input(name, *args)
- options = args.extract_options!
-
- fields = fields_for name do |f|
- (f.vanilla_text_field :value, class: "score-box", placeholder: options[:placeholder] || "",
- disabled: options[:disabled]) +
- (@template.content_tag :div do
- f.select(:kind, { "points" => "points", "%" => "percent" }, {},
- class: "carrot", disabled: options[:disabled])
- end)
- end
-
- wrap_field name, fields, options
- end
-
- def submit(text, *args)
- options = args.extract_options!
-
- options[:class] = "btn btn-primary #{options[:class]}"
-
- super text, *(args + [options])
- end
-
- def check_box(name, *args)
- options = args.extract_options!
-
- display_name = options[:display_name].nil? ? name : options[:display_name]
-
- display_span = "#{display_name.to_s.humanize} "
- # Materalize requires the label to be in a span
- field = super name, *(args + [options])
-
- if options[:default]
- return field
- end
-
- @template.content_tag :div do
- if options.include?(:help_text)
- label(name, field + display_span.html_safe,
- class: "control-label") + help_text(name, options[:help_text])
- else
- label(name, field + display_span.html_safe, class: "control-label")
- end
- end
- end
-
- def file_field(name, *args)
- options = args.extract_options!
-
- @template.content_tag :div, class: "file-field input-field" do
- (@template.content_tag :h6 do
- options[:label_text] || ""
- end) +
- (@template.content_tag :div, class: "btn" do
- (@template.content_tag :span do
- options[:button_text] || "Choose File"
- end) +
- vanilla_file_field(name, options)
- end) +
- (@template.content_tag :div, class: "file-path-wrapper" do
- if options.include?(:file_exists) && options.include?(:file_exists_text) && options[:file_exists]
- (@template.content_tag :input, nil, class: "file-path validate", type: "text",
- value: options[:file_exists_text]) +
- help_text(name, options[:help_text])
- else
- (@template.content_tag :input, nil, class: "file-path validate", type: "text",
- value: "No file selected") +
- help_text(name, options[:help_text])
- end
- end)
- end
- end
-
- def date_select(name, options = {}, _html_options = {})
- strftime = "%F"
- date_format = "F j, Y"
- alt_format = "M j Y"
- options[:picker_class] = "datepicker"
- date_helper name, options, strftime, date_format, alt_format
- end
-
- def datetime_select(name, options = {}, _html_options = {})
- strftime = "%F %H:%M %z"
- date_format = "YYYY-MM-DD HH:mm ZZ"
- alt_format = "YYYY-MM-DD HH:mm ZZ"
- options[:picker_class] = "datetimepicker"
- date_helper name, options, strftime, date_format, alt_format
- end
-
- private
-
- # Pass space-delimited list of IDs of datepickers on the :less_than and
- # :greater_than properties to initialize relationships between datepicker
- # fields.
- def date_helper(name, options, strftime, date_format, alt_format)
- begin
- existing_time = @object.send(name)
- rescue
- existing_time = nil
- end
-
- formatted_datetime = if existing_time.present?
- existing_time.strftime(strftime)
- else
- ""
- end
- field = vanilla_text_field(
- name,
- value: formatted_datetime,
- class: (options[:picker_class]).to_s,
- "data-date-format": date_format,
- "data-alt-format": alt_format,
- "data-date-less-than": options[:less_than],
- "data-date-greater-than": options[:greater_than]
- )
-
- wrap_field name, field, options
- end
-
- def wrap_field(name, field, options = {})
- @template.content_tag :div, class: options[:wrap_class] || "input-field" do
- label(name, options[:display_name], class: "control-label") +
- field + help_text(name, options[:help_text]) +
- error_text(name, options[:error_text])
- end
- end
-
- def help_text(_name, help_text)
- if help_text.nil?
- ""
- else
- @template.content_tag :p, help_text, class: "help-block"
- end
- end
-
- def error_text(_name, error_text)
- if error_text.nil?
- ""
- else
- @template.content_tag :p, error_text, id: "error-block"
- end
- end
-
- def objectify_options(options)
- super.except :help_text
- end
-end
+# Extend the Rails FormBuilder class.
+#
+# The naming is unfortunate, as this FormBuilder does more than just add a
+# custom datetimepicker. In reality, it's goal is to wrap common form builder
+# methods in Bootstrap boilerplate code.
+class FormBuilderWithDateTimeInput < ActionView::Helpers::FormBuilder
+ %w(text_field text_area email_field number_field file_field).each do |method_name|
+ # retain access to default textfield, etc. helpers
+ alias_method "vanilla_#{method_name}", method_name
+
+ define_method(method_name) do |name, *args|
+ options = args.extract_options!
+
+ # DEPRECATED: add form-control class (for Bootstrap styling) and pass on to Rails
+ options[:class] = (options[:class]).to_s
+
+ unless options.include?(:placeholder)
+ options[:placeholder] = ""
+ end
+ field = super name, *(args + [options])
+
+ wrap_field name, field, options
+ end
+ end
+
+ def score_adjustment_input(name, *args)
+ options = args.extract_options!
+
+ fields = fields_for name do |f|
+ (f.vanilla_text_field :value, class: "score-box", placeholder: options[:placeholder] || "",
+ disabled: options[:disabled]) +
+ (@template.content_tag :div do
+ f.select(:kind, { "points" => "points", "%" => "percent" }, {},
+ class: "carrot", disabled: options[:disabled])
+ end)
+ end
+
+ wrap_field name, fields, options
+ end
+
+ def submit(text, *args)
+ options = args.extract_options!
+
+ options[:class] = "btn btn-primary #{options[:class]}"
+
+ super text, *(args + [options])
+ end
+
+ def check_box(name, *args)
+ options = args.extract_options!
+
+ display_name = options[:display_name].nil? ? name : options[:display_name]
+
+ display_span = "#{display_name.to_s.humanize} "
+ # Materalize requires the label to be in a span
+ field = super name, *(args + [options])
+
+ if options[:default]
+ return field
+ end
+
+ @template.content_tag :div do
+ if options.include?(:help_text)
+ label(name, field + display_span.html_safe,
+ class: "control-label") + help_text(name, options[:help_text])
+ else
+ label(name, field + display_span.html_safe, class: "control-label")
+ end
+ end
+ end
+
+ def file_field(name, *args)
+ options = args.extract_options!
+
+ @template.content_tag :div, class: "file-field input-field" do
+ (@template.content_tag :h6 do
+ options[:label_text] || ""
+ end) +
+ (@template.content_tag :div, class: "btn" do
+ (@template.content_tag :span do
+ options[:button_text] || "Choose File"
+ end) +
+ vanilla_file_field(name, options)
+ end) +
+ (@template.content_tag :div, class: "file-path-wrapper" do
+ if options.include?(:file_exists) && options.include?(:file_exists_text) && options[:file_exists]
+ (@template.content_tag :input, nil, class: "file-path validate", type: "text",
+ value: options[:file_exists_text]) +
+ help_text(name, options[:help_text])
+ else
+ (@template.content_tag :input, nil, class: "file-path validate", type: "text",
+ value: "No file selected") +
+ help_text(name, options[:help_text])
+ end
+ end)
+ end
+ end
+
+ def date_select(name, options = {}, _html_options = {})
+ strftime = "%F"
+ date_format = "F j, Y"
+ alt_format = "M j Y"
+ options[:picker_class] = "datepicker"
+ date_helper name, options, strftime, date_format, alt_format
+ end
+
+ def datetime_select(name, options = {}, _html_options = {})
+ strftime = "%F %H:%M %z"
+ date_format = "YYYY-MM-DD HH:mm ZZ"
+ alt_format = "YYYY-MM-DD HH:mm ZZ"
+ options[:picker_class] = "datetimepicker"
+ date_helper name, options, strftime, date_format, alt_format
+ end
+
+ private
+
+ # Pass space-delimited list of IDs of datepickers on the :less_than and
+ # :greater_than properties to initialize relationships between datepicker
+ # fields.
+ def date_helper(name, options, strftime, date_format, alt_format)
+ begin
+ existing_time = @object.send(name)
+ rescue
+ existing_time = nil
+ end
+
+ formatted_datetime = if existing_time.present?
+ existing_time.strftime(strftime)
+ else
+ ""
+ end
+ field = vanilla_text_field(
+ name,
+ value: formatted_datetime,
+ class: (options[:picker_class]).to_s,
+ "data-date-format": date_format,
+ "data-alt-format": alt_format,
+ "data-date-less-than": options[:less_than],
+ "data-date-greater-than": options[:greater_than]
+ )
+
+ wrap_field name, field, options
+ end
+
+ def wrap_field(name, field, options = {})
+ @template.content_tag :div, class: options[:wrap_class] || "input-field" do
+ label(name, options[:display_name], class: "control-label") +
+ field + help_text(name, options[:help_text]) +
+ error_text(name, options[:error_text])
+ end
+ end
+
+ def help_text(_name, help_text)
+ if help_text.nil?
+ ""
+ else
+ @template.content_tag :p, help_text, class: "help-block"
+ end
+ end
+
+ def error_text(_name, error_text)
+ if error_text.nil?
+ ""
+ else
+ @template.content_tag :p, error_text, id: "error-block"
+ end
+ end
+
+ def objectify_options(options)
+ super.except :help_text
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 7f6a9e15c..fa58ac5b5 100755
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -5,6 +5,10 @@ def current_assessment_path
course_assessment_path(@course, @assessment)
end
+ def current_assessment_link
+ link_to @assessment.display_name, course_assessment_path(@course, @assessment)
+ end
+
# Older Helpers
def sort_td_class_helper(param)
result = 'class="sortup"' if params[:sort] == param
@@ -182,8 +186,8 @@ def external_javascript_include_tag(library)
version = "3.10.1" # latest is "4.17.21"
javascript_include_tag "#{cloudflare}/lodash.js/#{version}/lodash.min.js"
when "jquery.dataTables"
- version = "1.10.21"
- javascript_include_tag "#{cloudflare}/datatables/#{version}/js/jquery.dataTables.min.js"
+ version = "1.13.4"
+ javascript_include_tag "https://cdn.datatables.net/#{version}/js/jquery.dataTables.min.js"
when "flatpickr"
version = "4.6.13"
javascript_include_tag "#{cloudflare}/flatpickr/#{version}/flatpickr.min.js"
diff --git a/app/models/submission.rb b/app/models/submission.rb
index d11b2c594..056c799d1 100755
--- a/app/models/submission.rb
+++ b/app/models/submission.rb
@@ -255,6 +255,10 @@ def handin_file
end
end
+ def global_annotations
+ annotations.where(global_comment: true)
+ end
+
def annotated_file(file, filename, position)
conditions = { filename: }
conditions[:position] = position if position
@@ -392,8 +396,10 @@ def latest?
# override as_json to include the total with a parameter
def as_json(options = {})
json = super(options)
- json["total"] = final_score options[:seen_by]
- json["late_penalty"] = late_penalty options[:seen_by]
+ unless options[:seen_by].nil?
+ json["total"] = final_score options[:seen_by]
+ json["late_penalty"] = late_penalty options[:seen_by]
+ end
json["grace_days_used"] = grace_days_used
json["penalty_late_days"] = penalty_late_days
json["days_late"] = days_late
diff --git a/app/views/assessments/_excuse_popover.html.erb b/app/views/assessments/_excuse_popover.html.erb
new file mode 100644
index 000000000..dad38737d
--- /dev/null
+++ b/app/views/assessments/_excuse_popover.html.erb
@@ -0,0 +1,16 @@
+
diff --git a/app/views/assessments/show.html.erb b/app/views/assessments/show.html.erb
index 8d97ece57..a27b5710c 100755
--- a/app/views/assessments/show.html.erb
+++ b/app/views/assessments/show.html.erb
@@ -84,7 +84,7 @@
title: "View and enter grades for all sections" %>
<% unless @assessment.disable_handins %>
- <%= link_to "Download section #{@cud.section} submissions", downloadAll_course_assessment_submissions_path(@course, @assessment, { final: true }), title: "Download section #{@cud.section} submissions" %>
+ <%= link_to "Download section #{@cud.section} submissions", download_all_course_assessment_submissions_path(@course, @assessment, { final: true }), title: "Download section #{@cud.section} submissions" %>
<% end %>
Danger Zone
<%= link_to "Reload config file", reload_course_assessment_path(@course, @assessment), { title: "Reload the assessment config file (provided for backward compatibility with legacy assessments)", data: { confirm: "Are you sure you want to reload the config file?", method: "post" } } %>
diff --git a/app/views/submissions/_annotation.html.erb b/app/views/submissions/_annotation.html.erb
index 30cb160fd..30aacf73a 100644
--- a/app/views/submissions/_annotation.html.erb
+++ b/app/views/submissions/_annotation.html.erb
@@ -4,7 +4,7 @@
- <%= render "annotation_form", global: false %>
+ <%= render "annotation_form", global: false, popup: false %>