From 96e59958ea665e21c7b01f6498435e7244640398 Mon Sep 17 00:00:00 2001 From: lykimchee Date: Wed, 19 Apr 2023 16:01:32 -0400 Subject: [PATCH 01/14] Main Table UI Changes (#1886) * Start Manage Submissions * Center checkbox in manage submission table (#1868) fix checkbox issue * Main Table UI * Updates with selecting students and buttons * Add Score Popup Icon * Icon spacing and codebase style --------- Co-authored-by: Michelle Liu --- app/assets/javascripts/manage_submissions.js | 113 ++++------- app/assets/stylesheets/datatable.adapter.css | 56 ++---- app/assets/stylesheets/style.css.scss | 95 ++++++++- app/helpers/application_helper.rb | 13 +- app/views/submissions/index.html.erb | 198 ++++++++++--------- 5 files changed, 254 insertions(+), 221 deletions(-) diff --git a/app/assets/javascripts/manage_submissions.js b/app/assets/javascripts/manage_submissions.js index de37f533f..5d29683b6 100644 --- a/app/assets/javascripts/manage_submissions.js +++ b/app/assets/javascripts/manage_submissions.js @@ -1,78 +1,49 @@ -var hideStudent; - $(document).ready(function() { - $.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"); - } - } - ); - - 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(); - }; + // USE LATER FOR GROUPING ROWS (POSSIBLY): + + // $.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"); + // } + // } + // ); var table = $('#submissions').DataTable({ - 'sPaginationType': 'full_numbers', - 'iDisplayLength': 100, - 'oLanguage': { - 'sLengthMenu':'' - }, - "columnDefs": [{ - "targets": [8], - "visible": false, - // "searchable": false - }], - "aaSorting": [ - [4, "desc"] + "dom": 'fBrt', // show buttons, search, table + buttons: [ + { text: 'cachedRegrade Selected', className: 'btn submissions-selected disabled' }, + { text: 'delete_outlineDelete Selected', className: 'btn submissions-selected disabled' }, + { text: 'downloadDownload Selected', className: 'btn submissions-selected disabled' }, + { text: 'doneExcuse Selected', className: 'btn submissions-selected disabled' } ] }); - $("#only-latest").on("change", function() { - table.draw(); - }); - - var ids = []; - $("input[type='checkbox']:checked").each(function() { - ids.push($(this).val()); - }); - var selectedSubmissions = []; - var initialBatchUrl = $("#batch-regrade").prop("href"); + // USE LATER FOR REGRADE SELECTED (POSSIBLY): - function updateBatchRegradeButton() { + // var initialBatchUrl = $("#batch-regrade").prop("href"); - if (selectedSubmissions.length == 0) { - $("#batch-regrade").fadeOut(120); - } else { - $("#batch-regrade").fadeIn(120); - } - var urlParam = $.param({ - "submission_ids": selectedSubmissions - }); - var newHref = initialBatchUrl + "?" + urlParam; - $("#batch-regrade").html("Regrade " + selectedSubmissions.length + " Submissions") - $("#batch-regrade").prop("href", newHref); - }; + // function updateBatchRegradeButton() { + // if (selectedSubmissions.length == 0) { + // $("#batch-regrade").fadeOut(120); + // } else { + // $("#batch-regrade").fadeIn(120); + // } + // var urlParam = $.param({ + // "submission_ids": selectedSubmissions + // }); + // var newHref = initialBatchUrl + "?" + urlParam; + // $("#batch-regrade").html("Regrade " + selectedSubmissions.length + " Submissions") + // $("#batch-regrade").prop("href", newHref); + // }; function toggleRow(submissionId) { if (selectedSubmissions.indexOf(submissionId) < 0) { @@ -86,8 +57,7 @@ $(document).ready(function() { $("#row-" + submissionId).removeClass("selected"); selectedSubmissions = _.without(selectedSubmissions, submissionId); } - - updateBatchRegradeButton(); + // updateBatchRegradeButton(); } $("#submissions").on("click", ".exclude-click i", function (e) { @@ -95,17 +65,6 @@ $(document).ready(function() { return; }); - $('#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; - } - }); - $('#submissions').on("click", ".cbox", function(e) { var submissionId = parseInt(e.currentTarget.id.replace("cbox-", ""), 10); toggleRow(submissionId); diff --git a/app/assets/stylesheets/datatable.adapter.css b/app/assets/stylesheets/datatable.adapter.css index 8ca7f4eae..414607c28 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 { @@ -47,30 +32,27 @@ table.dataTable { clear: both; } -a.paginate_button, -a.paginate_active { - display: inline-block; - padding: 2px 4px; - margin-left: 2px; - cursor: pointer; - *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; } -div.dataTables_paginate span>a { - width: 15px; - text-align: center; +.dt-button span { + display: flex; } -th { - z-index: 1; - position: sticky; - top: 0; +.dt-button i { + margin: 0 6px 0 4px; } diff --git a/app/assets/stylesheets/style.css.scss b/app/assets/stylesheets/style.css.scss index 8c09fecd0..ab65400b1 100644 --- a/app/assets/stylesheets/style.css.scss +++ b/app/assets/stylesheets/style.css.scss @@ -742,7 +742,6 @@ div.field_with_errors { } /* 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, @@ -759,7 +758,6 @@ table.prettyBorder { tr { background-color: #fff; - border: 1px solid #d0d0d0; &:hover { background-color: #abcdef; } @@ -777,7 +775,7 @@ table.prettyBorder { th { background-color: #ebebeb; - color: #909090; + color: #000000; cursor: pointer; font-family: Source Sans Pro, sans-serif; font-size: 0.8em; @@ -787,11 +785,32 @@ table.prettyBorder { text-transform: uppercase; } + .submissions-th { + padding: 25px 0 25px 0; + } + td { border: 1px solid #ddd; padding: 0 5px; } + .submissions-td { + border: none; + border-bottom: 1px solid #ddd; + 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; } @@ -1129,6 +1148,7 @@ label[for="switch"] { .checkbox input[type="checkbox"]:checked + label::before { border: 1px solid #bbb; + } .new_submission { @@ -1455,10 +1475,6 @@ table.sub td, th { margin-left: 10px } -.submissions { - overflow-x: auto; -} - .submission-history-button { white-space: nowrap; text-transform: unset !important; @@ -1476,3 +1492,68 @@ table.sub td, th { .submission-history-button:hover { color: white !important; } + +/* 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-selected { + margin: 0; + padding: 0px 10px 0px 5px; + font-family: Source Sans Pro, sans-serif; +} + +.buttons-row { + display: flex; + flex-direction: row; +} + +.buttons-spacing { + margin-right: 10px; + a { + overflow-y: hidden; + } +} + +.submissions-checkbox { + margin-left: 35px; +} + +.submissions-center-icons { + display: flex; + align-items: center; +} + +.submissions-center-icons p { + margin: 0 0 0 10px; +} + +.submissions-icons { + margin-left: 3px; +} + +.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: #A1A0A3; +} diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 3e8b3072e..c5df5773d 100755 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -159,6 +159,8 @@ def external_stylesheet_link_tag(library) when "semantic-ui" version = "2.4.1" stylesheet_link_tag "#{cloudflare}/semantic-ui/#{version}/semantic.min.css" + when "datatables-rows" + stylesheet_link_tag "https://cdn.datatables.net/v/dt/dt-1.13.4/b-2.3.6/rg-1.3.1/datatables.min.css" end end @@ -168,7 +170,7 @@ def external_javascript_include_tag(library) # Update versions manually as-and-when newer versions become available on the CDN case library when "jquery" - version = "2.2.4" # latest is "3.6.0" + version = "3.5.1" # latest is "3.6.0" javascript_include_tag "#{cloudflare}/jquery/#{version}/jquery.min.js" when "jquery-ui" version = "1.12.1" @@ -177,8 +179,13 @@ 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 "datatables-buttons" + version = "2.3.6" + javascript_include_tag "https://cdn.datatables.net/buttons/#{version}/js/dataTables.buttons.min.js" + when "datatables-rows" + javascript_include_tag "https://cdn.datatables.net/v/dt/dt-1.13.4/b-2.3.6/rg-1.3.1/datatables.min.js" when "flatpickr" version = "4.6.13" javascript_include_tag "#{cloudflare}/flatpickr/#{version}/flatpickr.min.js" diff --git a/app/views/submissions/index.html.erb b/app/views/submissions/index.html.erb index 0dadc73f5..dbb17bca8 100755 --- a/app/views/submissions/index.html.erb +++ b/app/views/submissions/index.html.erb @@ -2,141 +2,145 @@ <% content_for :stylesheets do %> <%= stylesheet_link_tag "datatable.adapter" %> - - + <%= stylesheet_link_tag "datatables-rows" %> <% end %> <% content_for :javascripts do %> <%= external_javascript_include_tag "lodash" %> <%= javascript_include_tag "sorttable" %> <%= external_javascript_include_tag "jquery.dataTables" %> + <%= external_javascript_include_tag "datatables-buttons" %> <%= javascript_include_tag "manage_submissions" %> - + <%= javascript_include_tag "datatables-rows" %> <% end %> +

Manage Submissions


-

- <%= link_to "Create New Submission".html_safe, new_course_assessment_submission_path(@course, @assessment), +

+
+ <%= link_to "addCreate Submission".html_safe, + new_course_assessment_submission_path(@course, @assessment), {:title=>"Create a new submission for a student, with an option to submit a handin file on their behalf", - :class=>""} %> - | - <%= link_to "Download All Submissions".html_safe, - downloadAll_course_assessment_submissions_path(@course, @assessment), - {:title=>"Down all submissions from each student", - :class=>""} %> - | - <%= link_to "Download Final Submissions".html_safe, + :class=>"btn submissions-main"} %> +
+ +
+ <%= link_to "file_downloadDownload Final Submissions".html_safe, downloadAll_course_assessment_submissions_path(@course, @assessment, final: true), - {:title=>"Download the most recent submission from each student", - :class=>""} %> - | - <%= link_to "Missing Submissions".html_safe, + {:title=>"Download final submissions from each student", + :class=>"btn submissions-main"} %> +
+ +
+ <%= link_to "peopleMissing Submissions".html_safe, missing_course_assessment_submissions_path(@course, @assessment), - {:title=>"List the students who have not submitted anything. You'll be given the option to create new submissions for the missing students", - :class=>""} %> - -

-
- <% if @autograded then %> -
- <%= link_to "Regrade All", - [:regradeAll, @course, @assessment], - { method: :post, - :title=>"Regrade the most recent submission from each student", - :confirm=>"Are you sure you want to do this? It will regrade the most recent submission from each student, which might take a while.", - :class=>"btn"} %> + {:title=>"List the students who have not submitted anything", + :class=>"btn submissions-main"} %>
-
- - <%= link_to "Regrade 0", - [:regradeBatch, @course, @assessment], - { method: :post, - :title=>"Regrade the most recent submission from each student", - :class=>"btn float-right", - :id => "batch-regrade", - :style => "display:none;"} %> + +
+ <%= link_to "eventManage Extensions".html_safe, + [@course, @assessment, :extensions], + {:class=>"btn submissions-main"} %>
- <% end %>
+ + - <% headers = ["Submitted By", "Version", "Score", "Submission Date (YYYY-MM-DD)", "File", "IP Address", "Actions", "isLatest"] %> + <% headers = ["Submitted By", + "Version", + "Score", + "Submission Date", + "File", + "Actions"] %> - - <% for header in headers %> - - <% end %> + + <% for header in headers %> + + <% end %> + <% for submission in @submissions %> - + + <%# Submitted By %> + + + <%# Version %> + - - - + <%# Score %> + - + <%# Submission Date %> + - - - - - - - <% end %> + <% end %> <%# End loop over submissions %>
<%= header %><%= header %>
-

-

+
+ -

+
+
+ <%= [submission.course_user_datum.first_name, submission.course_user_datum.last_name].reject(&:blank?).join(' ') %> +
+ <%= submission.course_user_datum.email %> +
+ <%= submission.version %> <%= [submission.course_user_datum.last_name, submission.course_user_datum.first_name].reject(&:blank?).join(', ') %> - <%= link_to submission.course_user_datum.email, - history_course_assessment_path(@course, @assessment, cud_id: submission.course_user_datum_id, partial: true), - {remote: true, class: :trigger} - %><%= submission.version %> +
+
<%= computed_score { submission.final_score(@cud) } %>
+
zoom_in
+
+
<%= computed_score { submission.final_score(@cud) } %> + + <%= submission.created_at.in_time_zone.to_s %> + + <%= submission.created_at.in_time_zone.to_s %> + <%# File %> + <% if submission.filename then %> - <%= link_to "#{submission.filename}", - download_course_assessment_submission_path(@course, @assessment, submission) %> +
+ <%= link_to "zoom_in".html_safe, + [:view, @course, @assessment, submission], + {:title=>"View the file for this submission", + :class=>"btn small"} %> +

View File

+
<% else %> None <% end %>
- <%= submission.submitter_ip %> - - <% if @autograded and submission.version > 0 then %> - <%= button_to [:regrade, @course, @assessment, submission_id: submission.id], - :method => :post, - :title=>"Rerun the autograder on this submission", - :class=>"btn small" do %> - autorenew - <% end %> + + <%# Actions %> + + <% if @autograded then %> +
+ <%= button_to [:regrade, @course, @assessment, submission_id: submission.id], + :method => :post, + :title=>"Rerun the autograder on this submission", + :class=>"btn small" do %> + autorenew + <% end %> +

Regrade

+
<% end %> - <%= link_to "edit".html_safe, [:edit, @course, @assessment, submission], - {:title=>"Edit the grading properties of this submission", - :class=>"btn small"} %> - <%= link_to "delete".html_safe, destroyConfirm_course_assessment_submission_path(@course, @assessment, submission), - {:title=>"Destroy this submission forever", - :class=>"btn small"} %> +
+ <%= link_to "delete_outline".html_safe, + destroyConfirm_course_assessment_submission_path(@course, @assessment, submission), + {:title=>"Destroy this submission forever", + :class=>"btn small"} %> +

Delete

+
<%= submission.latest? %>
- -
-
- -
-
From ed5bf82c6af2306ce178cd0f0ab5606db03ca4b5 Mon Sep 17 00:00:00 2001 From: Michelle Liu Date: Sun, 23 Apr 2023 18:26:51 -0400 Subject: [PATCH 02/14] Add sorting icons to new manage submissions (#1890) * change icon to swap_vert, hide for file and actions headers * change icon on diff sort --- app/assets/stylesheets/style.css.scss | 30 +++++++++++++++++++++++++++ app/views/submissions/index.html.erb | 13 ++++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/style.css.scss b/app/assets/stylesheets/style.css.scss index ab65400b1..fd67dbbdc 100644 --- a/app/assets/stylesheets/style.css.scss +++ b/app/assets/stylesheets/style.css.scss @@ -787,6 +787,36 @@ table.prettyBorder { .submissions-th { padding: 25px 0 25px 0; + 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 { diff --git a/app/views/submissions/index.html.erb b/app/views/submissions/index.html.erb index dbb17bca8..60c41f5a2 100755 --- a/app/views/submissions/index.html.erb +++ b/app/views/submissions/index.html.erb @@ -7,7 +7,7 @@ <% content_for :javascripts do %> <%= external_javascript_include_tag "lodash" %> - <%= javascript_include_tag "sorttable" %> + <%= javascript_include_tag "sorttable" %> <%= external_javascript_include_tag "jquery.dataTables" %> <%= external_javascript_include_tag "datatables-buttons" %> <%= javascript_include_tag "manage_submissions" %> @@ -58,7 +58,16 @@ <% for header in headers %> - <%= header %> + +
+

<%= header %>

+ <% if header != "File" && header != "Actions" then %> + + + + <% end %> +
+ <% end %> From 3a9a25f46d40cfa2dac7b891662425ef4eb4483c Mon Sep 17 00:00:00 2001 From: Victor Huang Date: Sat, 13 May 2023 15:40:58 -0400 Subject: [PATCH 03/14] Adds Score Details (#1893) * Adds score details without styling * address general styling for score details * refactor code * address pr issues --- app/assets/javascripts/manage_submissions.js | 169 +++++++++++++++++- app/assets/stylesheets/manage_submissions.css | 18 ++ app/controllers/submissions_controller.rb | 32 ++++ app/views/submissions/index.html.erb | 21 ++- config/routes.rb | 3 +- 5 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 app/assets/stylesheets/manage_submissions.css diff --git a/app/assets/javascripts/manage_submissions.js b/app/assets/javascripts/manage_submissions.js index 5d29683b6..37a53f295 100644 --- a/app/assets/javascripts/manage_submissions.js +++ b/app/assets/javascripts/manage_submissions.js @@ -1,4 +1,169 @@ -$(document).ready(function() { +const manage_submissions_endpoints = { + 'score_details': 'submissions/score_details', +} + +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); + } + }) + }); +} + +$(document).ready(function () { + + $('.modal').modal(); + + $('.score-details').on('click', function () { + // Get the email + const course_user_datum_id = $(this).data('cuid'); + const email = $(this).data('email'); + + // 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 < 0 ? " (Autograded)" : ""; + return ` + ${problem.name} +
+ ${max_score} ${autograded} + `; + }).join(''); + + const submissions_body = data.submissions.map((submission) => { + + let tweak_value = data?.tweaks[submission.id]?.value ?? "None"; + if (tweak_value != "None" && tweak_value > 0) { + tweak_value = `+${tweak_value}`; + } + + // 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'); + + const view_button = submission.filename ? + `
+ + zoom_in + +

View Source

+
` + : "None"; + + const download_button = + /text/.test(submission.detected_mime_type) ? + `
+ + file_download + +

Download

+
` : + `
+ + file_download + +

Download

+
`; + + return ` + + + ${submission.version} + + + ${human_readable_created_at} + + + ${submission.total} + + ${submission.problems. + map((problem) => + `${data.scores[submission.id][problem.id]?.['score']}` + ).join('')} + + ${submission.late_penalty} + + + + ${tweak_value} + + + + ${view_button} + ${download_button} + + `; + }).join(''); + + const submissions_table = + `

Click on non-autograded problem scores to edit or leave a comment.

+ + + + + + + ${problem_headers} + + + + + + + ${submissions_body} + +
Version No.Submission DateFinal ScoreLate PenaltyTweakActions
+ `; + + $('#score-details-content').html(`
${submissions_table}
`); + $('#score-details-table').DataTable({ + "order": [[0, "desc"]], + "paging": false, + "info": false, + "searching": false,}); + + }).catch((err) => { + $('#score-details-content').html(` +
+
+
+ ${err} +
+
+
`); + }); + }); // USE LATER FOR GROUPING ROWS (POSSIBLY): @@ -65,7 +230,7 @@ $(document).ready(function() { return; }); - $('#submissions').on("click", ".cbox", function(e) { + $('#submissions').on("click", ".cbox", function (e) { var submissionId = parseInt(e.currentTarget.id.replace("cbox-", ""), 10); toggleRow(submissionId); e.stopPropagation(); diff --git a/app/assets/stylesheets/manage_submissions.css b/app/assets/stylesheets/manage_submissions.css new file mode 100644 index 000000000..7f5983c52 --- /dev/null +++ b/app/assets/stylesheets/manage_submissions.css @@ -0,0 +1,18 @@ +.modal-header { + float: right; + margin-bottom: 0; + padding-bottom: 0; +} + +.score-details { + cursor: pointer; +} + +#score-details-modal{ + width: 90%; + height: auto; +} + +.submissions-td { + padding-left: 0.6rem !important; +} diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index 32b6513da..4836126f1 100755 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -20,6 +20,38 @@ def index @autograded = @assessment.has_autograder? end + action_auth_level :score_details, :instructor + def score_details + cuid = params[: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 = {} + submissions.each do |submission| + tweaks[submission.id] = submission.tweak + end + + autograded = @assessment.has_autograder? + submissions = submissions.as_json(seen_by: @cud) + + render json: { submissions: submissions, + scores: submission_id_to_score_data, + tweaks: tweaks, + autograded: autograded }, status: :ok + rescue StandardError => e + render json: { error: e.message }, status: :not_found + nil + end + # this works action_auth_level :new, :instructor def new diff --git a/app/views/submissions/index.html.erb b/app/views/submissions/index.html.erb index 60c41f5a2..6638c7f37 100755 --- a/app/views/submissions/index.html.erb +++ b/app/views/submissions/index.html.erb @@ -3,6 +3,7 @@ <% content_for :stylesheets do %> <%= stylesheet_link_tag "datatable.adapter" %> <%= stylesheet_link_tag "datatables-rows" %> + <%= stylesheet_link_tag "manage_submissions" %> <% end %> <% content_for :javascripts do %> @@ -102,7 +103,13 @@
<%= computed_score { submission.final_score(@cud) } %>
-
zoom_in
+
@@ -153,3 +160,15 @@ <% end %> <%# End loop over submissions %> + + \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 5f5b1bf21..833f681b0 100755 --- a/config/routes.rb +++ b/config/routes.rb @@ -140,7 +140,7 @@ end resources :scores, only: [:create, :show, :update] - + member do get "destroyConfirm" get "download" @@ -150,6 +150,7 @@ collection do get "downloadAll" get "missing" + get "score_details" end end From 9fb21b7b2e748e851e6e674945535d7d43a9c415 Mon Sep 17 00:00:00 2001 From: Michelle Liu Date: Sun, 4 Feb 2024 16:28:33 +0800 Subject: [PATCH 04/14] Manage submissions rebase to master (#2047) * Lint views/submissions (#1969) * Begin linting views/submissions * finish linting views/submissions * address issues in code_viewer * Prevent spoofing the author of an annotation (#1985) * Prevent spoofing the author of an annotation (cherry picked from commit d2ab510fb455119f820a0540b02d44a430526a2f) * Remove submitted_by from createAnnotation * Update link to docs in PR template (#1991) Fix link to docs * Hide irrelevant cud fields for students (#1988) * Show edit CUD button for students * Hide irrelevant CUD fields from students * Lint views/autograders, fix help-block gap from input (#1963) * lint views/autograders, fix help block gap from input * update form path * Lint views/announcements and Touch-up UI (#1957) * lint views/announcements and touch-up UI * address nits * Add erblint to overcommit and github actions (#1994) * update erb-lint config, overcommit config to enable erb-lint as a pre-commit hook, run erblint --lint-all * update github actions to run erb-lint during linting phase * update pull request template to include erblint check * Display Grace Day usage on submission history table, improve management of assessment penalty settings (#1990) * Display of grace days used * Fix calculation of effective_late_penalty and effective_version_penalty * Show course default values when applicable * Show warning messages when late submissions allowed but config does not make sense * Fix tests * Update wording * Improve formatting * Revert changes to effective penalties * Simplify check * Add toggles * Update wording on courseFields * Fix version threshold logic * Correctly set version threshold to blank when using course default * Clear / default values when checkbox clicked * Remove bottom padding * Improve UI when checkboxes selected * Address AI nits * Handle malformed scoreboard results from autograder, fix error handling for scoreboards (#1982) * begin fixing broken redirects * add code to check that entries are arrays, return flash error if not valid entry * fix spacing * address nit * Add logging * Click into submissions from gradebook score (#1998) * Clickable gradebook scores * Only scores have links --------- Co-authored-by: kestertan * Switch mossnet clean to use rails root instead of tilde expansion (#1997) use Rails root join function instead of ~/ to make sure moss clean script works across systems * Merge pull request from GHSA-h8wq-ghfq-5hfx * fixes * Add validation for handout, writeup, and handin_directory * Avoid use of and * Check that handout/writeup exists before checking path (#2001) Move present? check to front * Adds warning when assessment.rb file upload isn't a .rb file (#1999) * preliminary working version * only validates .rb files --------- Co-authored-by: Damian Ho * Refactor Assessment name rules, remove config file requirement (#1987) * begin refactoring naming rules for assessments * continue working on file acceptance * add testing * fix autograde * work on backwards compatibility / revertibility * keep working on implementing revertability * Fix some code creating assessmentConfigFile before assessment id created * Add documentation to naming rules * add line about assessment name uniqueness * update error messages * fix tests * add error handling code to redirect user in case assessment config file can't be loaded, run robocop * address AI code review * remove redundant flash * Fix text * Fix reload assessment config button text * Add more error handling, revamp regex string to better reflect valid ruby module names, add better sanitization for display name -> name conversion, fix docs to reflect actually valid assessment names. * fix test * Address nits * Fix issue where assessment could affect another assessment's config file if they both had names that mapped to pre-PR config file name * Delete config/oauth_config.yml * Delete diff.patch * Delete assessment.patch * remove unnecessary files * more removals * Suppress confirmation dialog on edit assessment page when no changes made (#2004) * Extract logic, call functions directly * Remove extraneous space * Remove another extraneous space * Display submission version in gradebook (#2005) * First Commit: version info is on gradebook as new columns * Second commit: only add a ver column after each assignment * Delete database.docker.yml * Delete schema.rb * Deleted debug code * change gitignore to original version * Address nits * Fix tooltips * Simplify version logic * Stop overwriting headerCssClass * Fix tooltip for not_yet_submitted * Handle nil aud * Add version to gradebook CSV export * Render tooltips onMouseEnter too * Simplify version header * Simplify logic * Increase gradebook width --------- Co-authored-by: SimonMen65 Co-authored-by: Simon Men <60764463+SimonMen65@users.noreply.github.com> * Don't clear assessment penalty fields on initial load (#2006) * Don't clear on initial load * Remove extraneous spaces * No line breaks when generating base64 strings (#2008) * fix bug for long strings * Update base64.js using new TextEncoder * Show all courses for MOSS (#2015) * Show all courses, restore filter * Address AI nits * Fix use of autocomplete attribute * Add newline * Simplify toggleOptions implementation * Fix style of isArchive checkboxes * Correct use of javascript_include_tag * Fix failing test * Update styling of warning * Extract dropdown logic, use OR for filtering * Add newline * Add spacing between dropdowns * Use find instead of children, check for selector existence * Removed name from assessment yml (#1993) * Removed name from assessment yml * Modified test after removing name from assessment yml * Removed unnecessary test for wrong assessment name * Removed yml name check in assessments_controller --------- Co-authored-by: Nicholas Clark * Account for hooks in viewFeedback instead of feedback output (#2003) * preliminary working version * resolve merge conflicts * use submission.scores instead of feedback array * don't show non autograded scores in autograded scores tab * rabbit ai suggestions * more rabbit.ai nits * make finishedAutograding not an instance variable * Remove element overlapping scrollbar hitbox (#2009) * Remove element overlapping scrollbar hitbox * Move style to annotation.scss --------- Co-authored-by: Damian Ho * Attachment categories (#1983) * Add category_name field and update course attachment UI * Improve styling of list items * Remove anchor link for unreleased badge, simplify delete button logic * Hide assessment attachments from course landing page * Add release_at field, remove released field * Fix tests * Add fixtures * Simplify variable names * Remove bullet points * Group buttons together * Make font-family consistent * Hide category for assessment attachments * Add cancel button, remove delete button, improve styling * Improve migration to be backwards compatible / reversible * Use update instead of update_attribute * Display when attachment will be released * Update tests * Simplify code * Use Time instead of DateTime * Add download icon for students * Vertically align icons * Hide assessment attachments from course attachment index * Add vertical space above release date * Passwordless temporary login (#1984) * Passwordless temporary login created * Login using devise * User is not signed in before changing password * Removing unneeded files * Removing changes to user.rb * Removing unneeded files * Resetting password does not log you out * Added mailer * Added/removed newlines * Changed naming * Added checks for nil user or params * Error handling for passwords * Removed email after password reset * Added documentation * Updated documentation * Moved documentation to features * Renamed to admin-features * Added link in mkdocs.yml * Visual cue for assessments (#2016) * Add dates to assessment card * Add CSS formatting for date * Fix margin and card sizes to be more pretty * Show all students on gradesheet (#2019) * add course members with blank info if no submissions found * add email for no submission users * update bg color * Move submission version logic to be handled by AUD (#2024) * Move submission version logic to be handled by AUD * update migration variable naming * fix unit tests, version number for new auds * fix coderabbit issues * add version number to schema * change schema timestamp * Use ActiveStorage for attachments, add attachment size limit (#2023) * 1810 Use ActiveStorage for attachments * 1864 Add backwards compatibility to ActiveStorage Attachments * 1872 Add size limit to attachments * Set mime_type * Remove require * redirect to index on error * Rails 6.1.7.6 Migration (#2037) * Initial update to 6.1.7.6 * Lock fomantic-ui-sass to 2.8.8.1 * Update schema.rb * Avoid locking setup-ruby version * Include net-http in Gemfile to avoid errors * Run rubocop * Lock uri to 0.10.0 * Fix lint issue * Fix course_number values for roster export * Use flash for drop warning * Properly display submission errors * Only show invalid assessment warning to instructors * Ensure gradebook search bar renders correctly for CAs * Filter by lecture too when CA views section * Only show missing submissions from section if CA filters by section * Update tests * Better handling for submission errors * More specific error handling for save_entries * Better error display for statistics page * Return 404 for popover on non-existent submission * load Archive in files that use its methods * Update Ruby to 3.2.2, Misc fixes (#2040) * Update Ruby to 3.2.2 - update Capybara config so that it works with new ruby version and so that js can be enabled again on selenium test - update releaseSectionGrades redirect to go to viewGradesheet for CA's section - add some more status text / more informative flash when instructor drops student - redact tango key in getjob * Address nits, update bundler / github integration * add arm64-darwin-23 to platforms * fix users nit * Bump uri from 0.10.0 to 0.10.3 (#2039) Bumps [uri](https://github.com/ruby/uri) from 0.10.0 to 0.10.3. - [Release notes](https://github.com/ruby/uri/releases) - [Commits](https://github.com/ruby/uri/compare/v0.10.0...v0.10.3) --- updated-dependencies: - dependency-name: uri dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Update old migrations for Ruby 3 (#2044) Use splat on old migrations * Remove grading deadline (#2014) * Initial removal of grading_deadline * Add brackets around arguments to grading_complete? * Consistency fixes * Migration to remove grading deadline * Add guards to migration * Rename grading_complete? to grades_released? * Address issues from v2.8.0 testing, misc fixes/changes (#2038) * Fix command for promoting a user to admin * Extract aria/collapsible code, switch to path helpers * Automatically open first accordion * Fix docs for Tango info endpoint * Create user directory on autograde_done * Coalesce accordions, simplify js, remove admin options * Update doorkeeper translations * Remove extraneous quotes * Remove redundant / useless assessment nil check * Fix error display when calling downloadAll on invalid assessment * Simplify failure redirect logic for downloadAll * Deduplicate logic for autograde feedback path and handin file path * Remove unused ass_dir variable * Remove redundant gitignores * Uncoalesce accordions * Fix redirects for invalid assessment Previously, calling downloadAll with an invalid assessment led to infinite redirects * Update the API to allow retrieving group members (#1956) * Add a param to the index groups api to retriee group members * Add api show endpoint for groups * Update docs * Update api docs for groups#show * Compact group members api response * Move fetching group json to a private method * Remove empty line --------- Co-authored-by: Damian Ho * Update index and show docs for Groups API (#2045) Update index and show docs * Main Table UI Changes (#1886) * Start Manage Submissions * Center checkbox in manage submission table (#1868) fix checkbox issue * Main Table UI * Updates with selecting students and buttons * Add Score Popup Icon * Icon spacing and codebase style --------- Co-authored-by: Michelle Liu * Add sorting icons to new manage submissions (#1890) * change icon to swap_vert, hide for file and actions headers * change icon on diff sort * Adds Score Details (#1893) * Adds score details without styling * address general styling for score details * refactor code * address pr issues * bring back css * bring back div * add back class names * add back icons * addressed nits * address nits --------- Signed-off-by: dependabot[bot] Co-authored-by: Joey Wildman Co-authored-by: Nicholas Myers <32116122+NicholasMy@users.noreply.github.com> Co-authored-by: Damian Ho Co-authored-by: Kester Co-authored-by: kestertan Co-authored-by: SimonMen65 Co-authored-by: Simon Men <60764463+SimonMen65@users.noreply.github.com> Co-authored-by: Ugo <7947217+ugogon@users.noreply.github.com> Co-authored-by: Nicholas AJ Clark Co-authored-by: Nicholas Clark Co-authored-by: lykimchee Co-authored-by: Joanna Ge <45646252+jlge@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Umar Alkafaween <30433769+umar221b@users.noreply.github.com> Co-authored-by: Victor Huang --- .erb-lint.yml | 2 + .github/pull_request_template.md | 4 +- .github/workflows/rubyonrails.yml | 9 +- .gitignore | 5 +- .overcommit.yml | 3 + .ruby-version | 2 +- Dockerfile | 2 +- Gemfile | 34 +- Gemfile.lock | 440 +++---- README.md | 2 +- app/assets/javascripts/annotations.js | 1 - app/assets/javascripts/base64.js | 126 +- app/assets/javascripts/collapsible.js | 19 + app/assets/javascripts/dropdown.js | 18 + app/assets/javascripts/edit_assessment.js | 85 ++ app/assets/javascripts/gradebook.js | 46 +- app/assets/javascripts/gradesheet.js.erb | 6 +- .../init_handin_datetimepickers.js | 21 +- app/assets/javascripts/moss.js | 18 + app/assets/stylesheets/annotations.scss | 8 + app/assets/stylesheets/assessments.scss | 26 + app/assets/stylesheets/datatable.adapter.css | 8 + app/assets/stylesheets/gradesheet.css.scss | 2 +- .../stylesheets/instructor_gradebook.scss | 11 +- app/assets/stylesheets/style.css.scss | 19 +- app/controllers/annotations_controller.rb | 2 + .../api/v1/assessments_controller.rb | 14 +- app/controllers/api/v1/groups_controller.rb | 43 +- app/controllers/application_controller.rb | 4 +- app/controllers/assessment/autograde.rb | 6 +- app/controllers/assessment/grading.rb | 1067 +++++++++-------- app/controllers/assessment/handin.rb | 12 +- app/controllers/assessment/handout.rb | 9 + app/controllers/assessments_controller.rb | 244 ++-- app/controllers/attachments_controller.rb | 64 +- .../course_user_data_controller.rb | 8 +- app/controllers/courses_controller.rb | 23 +- app/controllers/extensions_controller.rb | 4 +- app/controllers/groups_controller.rb | 2 +- app/controllers/metrics_controller.rb | 4 +- app/controllers/scoreboards_controller.rb | 28 +- app/controllers/submissions_controller.rb | 39 +- app/controllers/users_controller.rb | 40 +- .../form_builder_with_date_time_input.rb | 18 +- app/helpers/assessment_autograde_core.rb | 20 +- app/helpers/assessment_handin_core.rb | 9 +- app/helpers/gradebook_helper.rb | 19 +- app/models/annotation.rb | 6 +- app/models/assessment.rb | 181 ++- app/models/assessment_user_datum.rb | 53 +- app/models/attachment.rb | 43 +- app/models/course.rb | 14 +- app/models/course_user_datum.rb | 8 +- app/models/github_integration.rb | 10 +- app/models/grade_matrix.rb | 12 +- app/models/oauth_device_flow_request.rb | 8 +- app/models/risk_condition.rb | 10 +- app/models/score.rb | 8 +- app/models/score_adjustment.rb | 4 +- app/models/submission.rb | 70 +- app/models/user.rb | 13 +- app/models/watchlist_instance.rb | 20 +- .../announcements/_announcement.html.erb | 4 +- .../_announcementFields.html.erb | 21 +- app/views/announcements/edit.html.erb | 7 +- app/views/announcements/index.html.erb | 18 +- app/views/announcements/new.html.erb | 6 +- app/views/assessments/_edit_basic.html.erb | 8 +- app/views/assessments/_edit_handin.html.erb | 12 +- .../assessments/_edit_penalties.html.erb | 54 +- app/views/assessments/_handin_form.html.erb | 2 +- app/views/assessments/_remarks_panel.html.erb | 2 +- app/views/assessments/_results_panel.html.erb | 2 +- .../_submission_history_row.html.erb | 9 +- .../_submission_history_table.html.erb | 6 +- app/views/assessments/bulkGrade.html.erb | 12 +- app/views/assessments/edit.html.erb | 25 +- app/views/assessments/history.html.erb | 2 +- app/views/assessments/index.html.erb | 68 +- app/views/assessments/show.html.erb | 205 ++-- app/views/assessments/statistics.html.erb | 6 + app/views/assessments/viewGradesheet.html.erb | 41 +- app/views/attachments/_attachment.html.erb | 38 +- .../attachments/_course_attachment.html.erb | 13 + app/views/attachments/_form.html.erb | 42 +- app/views/attachments/edit.html.erb | 2 +- app/views/attachments/new.html.erb | 2 +- app/views/autograders/_form.html.erb | 72 +- app/views/components/_dropdown_icon.html.erb | 25 - app/views/course_user_data/_fields.html.erb | 23 +- app/views/course_user_data/edit.html.erb | 2 +- app/views/course_user_data/new.html.erb | 4 +- app/views/course_user_data/show.html.erb | 20 +- app/views/courses/_courseFields.html.erb | 7 +- app/views/courses/_usersTable.html.erb | 2 +- app/views/courses/edit.html.erb | 2 +- app/views/courses/email.html.erb | 4 +- app/views/courses/manage.html.erb | 36 +- app/views/courses/moss.html.erb | 311 +++-- app/views/courses/new.html.erb | 2 +- app/views/courses/report_bug.html.erb | 2 +- app/views/devise/confirmations/new.html.erb | 2 +- app/views/devise/passwords/edit.html.erb | 2 +- app/views/devise/passwords/new.html.erb | 2 +- app/views/devise/registrations/edit.html.erb | 2 +- app/views/devise/unlocks/new.html.erb | 2 +- .../doorkeeper/applications/index.html.erb | 2 +- .../authorized_applications/index.html.erb | 2 +- app/views/gradebooks/student.html.erb | 31 +- app/views/gradebooks/view.html.erb | 10 +- app/views/groups/new.html.erb | 2 +- app/views/home/no_user.html.erb | 2 +- app/views/jobs/getjob.html.erb | 4 +- app/views/layouts/application.html.erb | 8 +- app/views/problems/edit.html.erb | 2 +- app/views/problems/new.html.erb | 2 +- app/views/schedulers/new.html.erb | 4 +- app/views/scoreboards/_error_icon.html.erb | 7 + app/views/scoreboards/_form.html.erb | 7 + app/views/scoreboards/show.html.erb | 15 +- app/views/submissions/_annotation.html.erb | 2 +- .../submissions/_annotation_form.html.erb | 18 +- .../submissions/_annotation_pane.html.erb | 34 +- .../submissions/_annotations_js.html.erb | 5 +- .../submissions/_code_symbol_tree.html.erb | 4 +- app/views/submissions/_code_viewer.html.erb | 59 +- app/views/submissions/_file_tree.html.erb | 17 +- app/views/submissions/_golden-layout.html.erb | 18 +- app/views/submissions/_grades.html.erb | 2 +- .../submissions/_version_dropdown.html.erb | 9 +- app/views/submissions/_version_links.html.erb | 6 +- .../submissions/annotationsHelp.html.erb | 42 - app/views/submissions/destroyConfirm.html.erb | 10 +- app/views/submissions/edit.html.erb | 16 +- app/views/submissions/index.html.erb | 104 +- app/views/submissions/missing.html.erb | 31 +- app/views/submissions/new.html.erb | 34 +- app/views/submissions/view.html.erb | 24 +- app/views/submissions/view.js.erb | 5 +- app/views/submissions/viewPDF.html.erb | 59 +- app/views/users/edit.html.erb | 2 +- app/views/users/show.html.erb | 7 + .../users/update_password_for_user.html.erb | 17 + bin/rails | 5 +- bin/rake | 5 +- bin/setup | 6 +- bin/spring | 14 + config.ru | 6 +- config/application.rb | 10 +- config/boot.rb | 2 +- config/environment.rb | 2 +- config/environments/development.rb | 22 +- config/environments/production.rb.template | 5 +- config/environments/test.rb | 17 +- config/initializers/backtrace_silencers.rb | 7 +- config/initializers/core_ext.rb | 11 + .../initializers/filter_parameter_logging.rb | 2 +- .../new_framework_defaults_6_1.rb | 67 ++ config/initializers/permissions_policy.rb | 11 + config/locales/doorkeeper.en.yml | 45 +- config/routes.rb | 7 +- db/migrate/007_add_instructor_to_users.rb | 2 +- .../20100723040512_add_gradebook_caching.rb | 2 +- ...7150918_remove_key_from_gradebook_cache.rb | 2 +- ...0100826234109_make_andrew_id_unique_key.rb | 2 +- .../20101220043448_add_disabled_to_courses.rb | 2 +- ...204025713_add_tweak_type_to_submissions.rb | 2 +- .../20120204061221_add_tweak_type_to_users.rb | 2 +- ...1040729_remove_unused_gradebook_caching.rb | 2 +- ...193510_add_category_name_to_attachments.rb | 5 + ...924161219_add_release_at_to_attachments.rb | 13 + ...163059_remove_released_from_attachments.rb | 5 + ...emove_grading_deadline_from_assessments.rb | 13 + ...te_active_storage_tables.active_storage.rb | 27 + ..._version_number_to_assessment_user_data.rb | 16 + ..._to_active_storage_blobs.active_storage.rb | 22 + ..._storage_variant_records.active_storage.rb | 27 + db/schema.rb | 53 +- docker/nginx.conf | 2 +- docs/api-interface.md | 30 +- docs/features/admin-features.md | 9 + docs/features/embedded-forms.md | 4 +- docs/instructors.md | 4 +- docs/lab.md | 51 +- docs/tango-rest.md | 2 +- lib/tasks/autolab.rake | 2 - mkdocs.yml | 1 + spec/api/v1/course_user_data_api_spec.rb | 30 +- spec/api/v1/courses_api_spec.rb | 4 +- spec/api/v1/device_flow_requests_spec.rb | 8 +- spec/api/v1/submission_roundtrip_spec.rb | 2 +- spec/api/v1/tango_mock.rb | 2 +- spec/contexts_helper.rb | 4 +- .../assessments_controller_spec.rb | 91 +- .../attachments_controller_spec.rb | 652 ++++++++-- spec/controllers/courses_controller_spec.rb | 8 +- .../submissions_controller_spec.rb | 4 +- spec/factories/assessments.rb | 1 - spec/factories/attachments.rb | 10 + .../homework02-illegal-assessment-name.tar | Bin 25600 -> 0 bytes .../homework02-legal-name-no-config.tar | Bin 0 -> 13312 bytes spec/fixtures/attachments/assessment.txt | 1 + spec/fixtures/attachments/attachment.txt | 1 + spec/fixtures/attachments/course.txt | 1 + spec/rails_helper.rb | 20 +- spec/sets/assessments.rb | 2 +- spec/sets/courses.rb | 6 +- spec/sets/submissions.rb | 11 +- spec/support/controller_macros.rb | 45 +- 209 files changed, 3775 insertions(+), 2482 deletions(-) create mode 100644 app/assets/javascripts/collapsible.js create mode 100644 app/assets/javascripts/dropdown.js create mode 100644 app/assets/javascripts/moss.js create mode 100644 app/assets/stylesheets/assessments.scss mode change 100755 => 100644 app/models/user.rb create mode 100644 app/views/attachments/_course_attachment.html.erb delete mode 100644 app/views/components/_dropdown_icon.html.erb create mode 100644 app/views/scoreboards/_error_icon.html.erb delete mode 100755 app/views/submissions/annotationsHelp.html.erb create mode 100644 app/views/users/update_password_for_user.html.erb create mode 100755 bin/spring create mode 100644 config/initializers/core_ext.rb create mode 100644 config/initializers/new_framework_defaults_6_1.rb create mode 100644 config/initializers/permissions_policy.rb create mode 100644 db/migrate/20230912193510_add_category_name_to_attachments.rb create mode 100644 db/migrate/20230924161219_add_release_at_to_attachments.rb create mode 100644 db/migrate/20230924163059_remove_released_from_attachments.rb create mode 100644 db/migrate/20231201202449_remove_grading_deadline_from_assessments.rb create mode 100644 db/migrate/20231208034215_create_active_storage_tables.active_storage.rb create mode 100644 db/migrate/20231208083728_add_version_number_to_assessment_user_data.rb create mode 100644 db/migrate/20240101084756_add_service_name_to_active_storage_blobs.active_storage.rb create mode 100644 db/migrate/20240101084757_create_active_storage_variant_records.active_storage.rb create mode 100644 docs/features/admin-features.md create mode 100644 spec/factories/attachments.rb delete mode 100644 spec/fixtures/assessments/homework02-illegal-assessment-name.tar create mode 100644 spec/fixtures/assessments/homework02-legal-name-no-config.tar create mode 100644 spec/fixtures/attachments/assessment.txt create mode 100644 spec/fixtures/attachments/attachment.txt create mode 100644 spec/fixtures/attachments/course.txt diff --git a/.erb-lint.yml b/.erb-lint.yml index dcf52b268..bbf458ba3 100644 --- a/.erb-lint.yml +++ b/.erb-lint.yml @@ -1,5 +1,7 @@ glob: "app/**/*.{html,text,js}{+*,}.erb" EnableDefaultLinters: true +exclude: + - '**/javascripts/*' linters: HardCodedString: enabled: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 14a88ddfe..ba3f6c1e2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -23,8 +23,8 @@ reviewpad:summary ## Checklist: -- [ ] I have run rubocop for style check. If you haven't, run `overcommit --install && overcommit --sign` to use pre-commit hook for linting -- [ ] My change requires a change to the documentation, which is located at [Autolab Docs](https://github.com/autolab/docs) +- [ ] I have run rubocop and erblint for style check. If you haven't, run `overcommit --install && overcommit --sign` to use pre-commit hook for linting +- [ ] My change requires a change to the documentation, which is located at [Autolab Docs](https://docs.autolabproject.com/) - [ ] I have updated the documentation accordingly, included in this PR ## Other issues / help required diff --git a/.github/workflows/rubyonrails.yml b/.github/workflows/rubyonrails.yml index 08f9a612c..d6bb561d1 100644 --- a/.github/workflows/rubyonrails.yml +++ b/.github/workflows/rubyonrails.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v3 - name: Install Ruby - uses: ruby/setup-ruby@v1.134.0 + uses: ruby/setup-ruby@v1 with: bundler-cache: true cache-version: 3 @@ -29,6 +29,9 @@ jobs: - name: Rubocop run: bundle exec rubocop + - name: Erblint + run: bundle exec erblint --lint-all + test: runs-on: ubuntu-latest @@ -42,7 +45,7 @@ jobs: uses: actions/checkout@v3 - name: Install Ruby - uses: ruby/setup-ruby@v1.134.0 + uses: ruby/setup-ruby@v1 with: bundler-cache: true cache-version: 3 @@ -53,7 +56,7 @@ jobs: cp config/school.yml.template config/school.yml cp config/autogradeConfig.rb.template config/autogradeConfig.rb cp .env.template .env - mkdir attachments/ tmp/ + mkdir tmp/ - name: Set up database run: | diff --git a/.gitignore b/.gitignore index 9228e0db3..98afb7be1 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ config/lti_platform_jwk.json app/views/home/_topannounce.html.erb attachments/ doc/ +storage/ out.txt # compiled assets @@ -46,10 +47,6 @@ tags .DS_Store .idea/ .yardoc -app/views/home/_topannounce.html.erb -attachments/ -doc/ -out.txt .vscode/ .byebug_history node_modules/ diff --git a/.overcommit.yml b/.overcommit.yml index 6abc3841e..35551f30f 100644 --- a/.overcommit.yml +++ b/.overcommit.yml @@ -21,6 +21,9 @@ PreCommit: RuboCop: enabled: true on_warn: fail # Treat all warnings as failures + ErbLint: + enabled: true + on_warn: fail # TrailingWhitespace: # enabled: true diff --git a/.ruby-version b/.ruby-version index 1f7da99d4..be94e6f53 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.7.7 +3.2.2 diff --git a/Dockerfile b/Dockerfile index 6b435bd89..5f3bed2b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ # https://github.com/phusion/passenger-docker # # -FROM phusion/passenger-ruby27:2.5.0 +FROM phusion/passenger-ruby32:2.6.1 MAINTAINER Autolab Development Team "autolab-dev@andrew.cmu.edu" diff --git a/Gemfile b/Gemfile index e4c4826c0..9e43055c3 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source 'https://rubygems.org' -ruby '2.7.7' +ruby '3.2.2' -gem 'rails', '=6.0.5' +gem 'rails', '=6.1.7.6' # Use SCSS for stylesheets gem 'sass-rails', '>= 4.0.3' @@ -13,7 +13,7 @@ gem 'materialize-sass', "=1.0.0" gem 'bootstrap-sass', '>= 3.4.1' # Use for Metrics page -gem 'fomantic-ui-sass' +gem 'fomantic-ui-sass', '2.8.8.1' # Use Uglifier as compressor for JavaScript assets gem 'terser', '>= 1.1.7' @@ -45,11 +45,11 @@ gem 'slack-notifier' gem 'exception_notification', ">= 4.1.0" # Used by lib/tasks/autolab.rake to populate DB with dummy seed data -gem 'rake', '>=10.3.2' gem 'populator', '>=1.0.0' +gem 'rake', '>=10.3.2' # To communicate with MySQL database -gem 'mysql2', '~>0.4.10' +gem 'mysql2', '~>0.5' # Development server gem 'thin' @@ -83,20 +83,19 @@ gem 'rubyzip' gem 'httparty' # Enables RSpec testing framework with Capybara and FactoryBot. -gem 'rspec-rails', '>=3.5.0' -gem 'rack-test' gem 'capybara', group: [:development, :test] +gem 'rack-test' +gem 'rspec-rails', '>=3.5.0' # To enable webdriver testing capabilities along with capybara -gem 'selenium-webdriver', '>=4.7.1', group: :test -# required to run webdriver for selenium on chrome -gem 'webdrivers', group: :test +gem 'selenium-webdriver', '>=4.16', group: :test +gem "webrick", "~> 1.8" # required for capybara debugging -gem 'launchy', group: :test -gem 'factory_bot_rails', group: [:development, :test] -gem 'database_cleaner', group: [:development, :test] -gem 'webmock', group: [:development, :test] gem 'codeclimate-test-reporter', group: :test, require: nil +gem 'database_cleaner', group: [:development, :test] +gem 'factory_bot_rails', group: [:development, :test] +gem 'launchy', group: :test gem 'newrelic_rpm' +gem 'webmock', group: [:development, :test] # Automatic Time Zone Management gem 'browser-timezone-rails' @@ -112,9 +111,9 @@ gem 'js_cookie_rails' # gem 'capistrano-rails', group: :development # Dates and times +gem 'bootstrap3-datetimepicker-rails', '>= 4.17.47' gem 'momentjs-rails', '>= 2.9.0' gem 'moment_timezone-rails' -gem 'bootstrap3-datetimepicker-rails', '>= 4.17.47' # Force SSL on certain routes gem 'rack-ssl-enforcer' @@ -125,6 +124,7 @@ group :development do gem 'binding_of_caller' # enhances better_errors # static code analyzer + gem 'erb_lint', require: false gem 'rubocop', require: false gem 'rubocop-rails', require: false @@ -170,4 +170,6 @@ gem 'lockbox' # to decode / verify jwts for LTI Integration gem "jwt" -gem 'erb_lint', require: false +# Avoid "already initialized constant" errors (https://github.com/ruby/net-imap/issues/16) +gem "net-http" +gem 'uri', '0.10.3' diff --git a/Gemfile.lock b/Gemfile.lock index 85a6a46fe..7f582b3a2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,73 +2,78 @@ GEM remote: https://rubygems.org/ specs: Ascii85 (1.0.3) - actioncable (6.0.5) - actionpack (= 6.0.5) + actioncable (6.1.7.6) + actionpack (= 6.1.7.6) + activesupport (= 6.1.7.6) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.0.5) - actionpack (= 6.0.5) - activejob (= 6.0.5) - activerecord (= 6.0.5) - activestorage (= 6.0.5) - activesupport (= 6.0.5) + actionmailbox (6.1.7.6) + actionpack (= 6.1.7.6) + activejob (= 6.1.7.6) + activerecord (= 6.1.7.6) + activestorage (= 6.1.7.6) + activesupport (= 6.1.7.6) mail (>= 2.7.1) - actionmailer (6.0.5) - actionpack (= 6.0.5) - actionview (= 6.0.5) - activejob (= 6.0.5) + actionmailer (6.1.7.6) + actionpack (= 6.1.7.6) + actionview (= 6.1.7.6) + activejob (= 6.1.7.6) + activesupport (= 6.1.7.6) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.0.5) - actionview (= 6.0.5) - activesupport (= 6.0.5) - rack (~> 2.0, >= 2.0.8) + actionpack (6.1.7.6) + actionview (= 6.1.7.6) + activesupport (= 6.1.7.6) + rack (~> 2.0, >= 2.0.9) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.0.5) - actionpack (= 6.0.5) - activerecord (= 6.0.5) - activestorage (= 6.0.5) - activesupport (= 6.0.5) + actiontext (6.1.7.6) + actionpack (= 6.1.7.6) + activerecord (= 6.1.7.6) + activestorage (= 6.1.7.6) + activesupport (= 6.1.7.6) nokogiri (>= 1.8.5) - actionview (6.0.5) - activesupport (= 6.0.5) + actionview (6.1.7.6) + activesupport (= 6.1.7.6) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (6.0.5) - activesupport (= 6.0.5) + activejob (6.1.7.6) + activesupport (= 6.1.7.6) globalid (>= 0.3.6) - activemodel (6.0.5) - activesupport (= 6.0.5) - activerecord (6.0.5) - activemodel (= 6.0.5) - activesupport (= 6.0.5) - activestorage (6.0.5) - actionpack (= 6.0.5) - activejob (= 6.0.5) - activerecord (= 6.0.5) + activemodel (6.1.7.6) + activesupport (= 6.1.7.6) + activerecord (6.1.7.6) + activemodel (= 6.1.7.6) + activesupport (= 6.1.7.6) + activestorage (6.1.7.6) + actionpack (= 6.1.7.6) + activejob (= 6.1.7.6) + activerecord (= 6.1.7.6) + activesupport (= 6.1.7.6) marcel (~> 1.0) - activesupport (6.0.5) + mini_mime (>= 1.1.0) + activesupport (6.1.7.6) concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 0.7, < 2) - minitest (~> 5.1) - tzinfo (~> 1.1) - zeitwerk (~> 2.2, >= 2.2.2) - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) + addressable (2.8.6) + public_suffix (>= 2.0.2, < 6.0) afm (0.2.2) ast (2.4.2) - autoprefixer-rails (10.4.7.0) + autoprefixer-rails (10.4.16.0) execjs (~> 2) - bcrypt (3.1.18) - better_errors (2.9.1) - coderay (>= 1.0.0) + base64 (0.2.0) + bcrypt (3.1.20) + better_errors (2.10.1) erubi (>= 1.0.0) rack (>= 0.9.0) - better_html (2.0.1) + rouge (>= 1.0.0) + better_html (2.0.2) actionview (>= 6.0) activesupport (>= 6.0) ast (~> 2.0) @@ -87,7 +92,7 @@ GEM rails (>= 3.1) builder (3.2.4) byebug (11.1.3) - capybara (3.36.0) + capybara (3.39.2) addressable matrix mini_mime (>= 0.1.3) @@ -99,7 +104,6 @@ GEM childprocess (4.1.0) codeclimate-test-reporter (1.0.9) simplecov (<= 0.13) - coderay (1.1.3) coffee-rails (5.0.0) coffee-script (>= 2.2.0) railties (>= 5.2.0) @@ -112,14 +116,15 @@ GEM rexml crass (1.0.6) daemons (1.4.1) - database_cleaner (2.0.1) - database_cleaner-active_record (~> 2.0.0) - database_cleaner-active_record (2.0.1) + database_cleaner (2.0.2) + database_cleaner-active_record (>= 2, < 3) + database_cleaner-active_record (2.1.0) activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) - debug_inspector (1.1.0) - devise (4.8.1) + date (3.3.4) + debug_inspector (1.2.0) + devise (4.9.3) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) @@ -127,14 +132,16 @@ GEM warden (~> 1.2.3) diff-lcs (1.5.0) docile (1.1.5) - doorkeeper (5.6.6) + doorkeeper (5.6.8) railties (>= 5) - dotenv (2.7.6) - dotenv-rails (2.7.6) - dotenv (= 2.7.6) + dotenv (2.8.1) + dotenv-rails (2.8.1) + dotenv (= 2.8.1) railties (>= 3.2) - dynamic_form (1.1.4) - erb_lint (0.4.0) + dynamic_form (1.3.1) + actionview (> 5.2.0) + activemodel (> 5.2.0) + erb_lint (0.5.0) activesupport better_html (>= 2.0.1) parser (>= 2.7.1.4) @@ -146,26 +153,27 @@ GEM exception_notification (4.5.0) actionmailer (>= 5.2, < 8) activesupport (>= 5.2, < 8) - execjs (2.8.1) - factory_bot (6.2.1) + execjs (2.9.1) + factory_bot (6.4.5) activesupport (>= 5.0.0) - factory_bot_rails (6.2.0) - factory_bot (~> 6.2.0) + factory_bot_rails (6.4.3) + factory_bot (~> 6.4) railties (>= 5.0.0) - faraday (2.3.0) - faraday-net_http (~> 2.0) + faraday (2.8.1) + base64 + faraday-net_http (>= 2.0, < 3.1) ruby2_keywords (>= 0.0.4) - faraday-net_http (2.0.3) - ffi (1.15.5) - fomantic-ui-sass (2.8.8) + faraday-net_http (3.0.2) + ffi (1.16.3) + fomantic-ui-sass (2.8.8.1) autoprefixer-rails - rails (>= 3.2.0) + railties (>= 3.2.0) sassc (>= 2.2) sassc-rails (>= 2.1) sprockets-rails (>= 2.1.3) - globalid (1.0.1) - activesupport (>= 5.0) - hashdiff (1.0.1) + globalid (1.2.1) + activesupport (>= 6.1) + hashdiff (1.1.0) hashery (2.1.2) hashie (5.0.0) httparty (0.21.0) @@ -177,28 +185,31 @@ GEM jbuilder (2.11.5) actionview (>= 5.0.0) activesupport (>= 5.0.0) - jquery-rails (4.5.0) + jquery-rails (4.6.0) rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) js_cookie_rails (2.2.0) railties (>= 3.1) - json (2.6.2) + json (2.7.1) jstz-rails3-plus (1.0.5) railties (>= 3.1) - jwt (2.5.0) + jwt (2.7.1) + language_server-protocol (3.17.0.3) launchy (2.5.2) addressable (~> 2.8) - libv8-node (16.10.0.0-arm64-darwin) - libv8-node (16.10.0.0-x86_64-darwin) - libv8-node (16.10.0.0-x86_64-darwin-19) - libv8-node (16.10.0.0-x86_64-linux) - lockbox (1.0.0) - loofah (2.21.3) + libv8-node (16.19.0.1-arm64-darwin) + libv8-node (16.19.0.1-x86_64-darwin) + libv8-node (16.19.0.1-x86_64-linux) + lockbox (1.3.0) + loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) - mail (2.7.1) + mail (2.8.1) mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp marcel (1.0.2) materialize-sass (1.0.0) autoprefixer-rails (>= 6.0.3) @@ -207,59 +218,73 @@ GEM mimemagic (0.4.3) nokogiri (~> 1) rake - mini_mime (1.1.2) - mini_racer (0.6.3) - libv8-node (~> 16.10.0.0) - minitest (5.18.0) + mini_mime (1.1.5) + mini_portile2 (2.8.5) + mini_racer (0.6.4) + libv8-node (~> 16.19.0.0) + minitest (5.20.0) moment_timezone-rails (0.5.14) momentjs-rails (~> 2.15.1) momentjs-rails (2.15.1) railties (>= 3.1) - multi_json (1.15.0) multi_xml (0.6.0) - mysql2 (0.4.10) - net-ldap (0.17.1) - newrelic_rpm (8.8.0) - nio4r (2.5.8) - nokogiri (1.15.2-arm64-darwin) + mysql2 (0.5.5) + net-http (0.4.0) + uri + net-imap (0.4.9) + date + net-protocol + net-ldap (0.18.0) + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.4.0) + net-protocol + newrelic_rpm (9.6.0) + base64 + nio4r (2.7.0) + nokogiri (1.15.5-arm64-darwin) racc (~> 1.4) - nokogiri (1.15.2-x86_64-darwin) + nokogiri (1.15.5-x86_64-darwin) racc (~> 1.4) - nokogiri (1.15.2-x86_64-linux) + nokogiri (1.15.5-x86_64-linux) racc (~> 1.4) - oauth2 (1.4.10) + oauth2 (2.0.9) faraday (>= 0.17.3, < 3.0) jwt (>= 1.0, < 3.0) - multi_json (~> 1.3) multi_xml (~> 0.5) - rack (>= 1.2, < 3) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0) + version_gem (~> 1.1) octokit (4.25.1) faraday (>= 1, < 3) sawyer (~> 0.9) - omniauth (2.1.0) + omniauth (2.1.2) hashie (>= 3.4.6) rack (>= 2.2.3) rack-protection omniauth-facebook (9.0.0) omniauth-oauth2 (~> 1.2) - omniauth-google-oauth2 (1.0.1) + omniauth-google-oauth2 (1.1.1) jwt (>= 2.0) - oauth2 (~> 1.1) + oauth2 (~> 2.0.6) omniauth (~> 2.0) - omniauth-oauth2 (~> 1.7.1) - omniauth-oauth2 (1.7.3) + omniauth-oauth2 (~> 1.8.0) + omniauth-oauth2 (1.8.0) oauth2 (>= 1.4, < 3) - omniauth (>= 1.9, < 3) + omniauth (~> 2.0) omniauth-shibboleth-redux (2.0.0) omniauth (>= 2.0.0) orm_adapter (0.5.0) - overcommit (0.59.1) + overcommit (0.61.0) childprocess (>= 0.6.3, < 5) iniparse (~> 1.4) rexml (~> 3.2) - parallel (1.22.1) - parser (3.1.2.0) + parallel (1.24.0) + parser (3.2.2.4) ast (~> 2.4.1) + racc pdf-reader (1.4.1) Ascii85 (~> 1.0.0) afm (~> 0.2.1) @@ -272,88 +297,93 @@ GEM pdf-reader (~> 1.2) ruby-rc4 ttfunk (~> 1.0.3) - psych (5.1.0) + psych (5.1.2) stringio - public_suffix (4.0.7) - racc (1.7.0) - rack (2.2.7) - rack-attack (6.6.1) - rack (>= 1.0, < 3) - rack-protection (2.2.0) - rack + public_suffix (5.0.4) + racc (1.7.3) + rack (2.2.8) + rack-attack (6.7.0) + rack (>= 1.0, < 4) + rack-protection (3.2.0) + base64 (>= 0.1.0) + rack (~> 2.2, >= 2.2.4) rack-ssl-enforcer (0.2.9) - rack-test (2.0.2) + rack-test (2.1.0) rack (>= 1.3) - rails (6.0.5) - actioncable (= 6.0.5) - actionmailbox (= 6.0.5) - actionmailer (= 6.0.5) - actionpack (= 6.0.5) - actiontext (= 6.0.5) - actionview (= 6.0.5) - activejob (= 6.0.5) - activemodel (= 6.0.5) - activerecord (= 6.0.5) - activestorage (= 6.0.5) - activesupport (= 6.0.5) - bundler (>= 1.3.0) - railties (= 6.0.5) + rails (6.1.7.6) + actioncable (= 6.1.7.6) + actionmailbox (= 6.1.7.6) + actionmailer (= 6.1.7.6) + actionpack (= 6.1.7.6) + actiontext (= 6.1.7.6) + actionview (= 6.1.7.6) + activejob (= 6.1.7.6) + activemodel (= 6.1.7.6) + activerecord (= 6.1.7.6) + activestorage (= 6.1.7.6) + activesupport (= 6.1.7.6) + bundler (>= 1.15.0) + railties (= 6.1.7.6) sprockets-rails (>= 2.0.0) - rails-dom-testing (2.0.3) - activesupport (>= 4.2.0) + rails-dom-testing (2.2.0) + activesupport (>= 5.0.0) + minitest nokogiri (>= 1.6) rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) - railties (6.0.5) - actionpack (= 6.0.5) - activesupport (= 6.0.5) + railties (6.1.7.6) + actionpack (= 6.1.7.6) + activesupport (= 6.1.7.6) method_source - rake (>= 0.8.7) - thor (>= 0.20.3, < 2.0) + rake (>= 12.2) + thor (~> 1.0) rainbow (3.1.1) - rake (13.0.6) - rdoc (6.4.0) + rake (13.1.0) + rdoc (6.6.2) psych (>= 4.0.0) - regexp_parser (2.5.0) - responders (3.0.1) - actionpack (>= 5.0) - railties (>= 5.0) - rexml (3.2.5) - rspec-core (3.11.0) - rspec-support (~> 3.11.0) - rspec-expectations (3.11.0) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.11.0) - rspec-mocks (3.11.1) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.11.0) - rspec-rails (5.1.2) + regexp_parser (2.8.3) + responders (3.1.1) actionpack (>= 5.2) - activesupport (>= 5.2) railties (>= 5.2) - rspec-core (~> 3.10) - rspec-expectations (~> 3.10) - rspec-mocks (~> 3.10) - rspec-support (~> 3.10) - rspec-support (3.11.0) - rubocop (1.31.2) + rexml (3.2.6) + rouge (4.2.0) + rspec-core (3.12.2) + rspec-support (~> 3.12.0) + rspec-expectations (3.12.3) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-mocks (3.12.6) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-rails (6.1.0) + actionpack (>= 6.1) + activesupport (>= 6.1) + railties (>= 6.1) + rspec-core (~> 3.12) + rspec-expectations (~> 3.12) + rspec-mocks (~> 3.12) + rspec-support (~> 3.12) + rspec-support (3.12.1) + rubocop (1.59.0) json (~> 2.3) + language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 3.1.0.0) + parser (>= 3.2.2.4) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.18.0, < 2.0) + rubocop-ast (>= 1.30.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.19.1) - parser (>= 3.1.1.0) - rubocop-rails (2.15.2) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.30.0) + parser (>= 3.2.1.0) + rubocop-rails (2.23.1) activesupport (>= 4.2.0) rack (>= 1.1) - rubocop (>= 1.7.0, < 2.0) - ruby-progressbar (1.11.0) + rubocop (>= 1.33.0, < 2.0) + rubocop-ast (>= 1.30.0, < 2.0) + ruby-progressbar (1.13.0) ruby-rc4 (0.1.5) ruby2_keywords (0.0.5) rubyzip (2.3.2) @@ -370,9 +400,9 @@ GEM sawyer (0.9.2) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) - sdoc (2.4.0) + sdoc (2.6.1) rdoc (>= 5.0) - selenium-webdriver (4.8.0) + selenium-webdriver (4.16.0) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) @@ -383,59 +413,61 @@ GEM simplecov-html (0.10.2) slack-notifier (2.4.0) smart_properties (1.17.0) - spring (3.1.1) - sprockets (4.1.1) + snaky_hash (2.0.1) + hashie + version_gem (~> 1.1, >= 1.1.1) + spring (4.1.3) + sprockets (4.2.1) concurrent-ruby (~> 1.0) - rack (> 1, < 3) + rack (>= 2.2.4, < 4) sprockets-rails (3.4.2) actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) - sqlite3 (1.5.1-arm64-darwin) - sqlite3 (1.5.1-x86_64-darwin) - sqlite3 (1.5.1-x86_64-linux) - stringio (3.0.7) - terser (1.1.11) + sqlite3 (1.5.1) + mini_portile2 (~> 2.8.0) + stringio (3.1.0) + terser (1.1.20) execjs (>= 0.3.0, < 3) - thin (1.8.1) + thin (1.8.2) daemons (~> 1.0, >= 1.0.9) eventmachine (~> 1.0, >= 1.0.4) rack (>= 1, < 3) - thor (1.2.2) - thread_safe (0.3.6) - tilt (2.0.10) + thor (1.3.0) + tilt (2.3.0) + timeout (0.4.1) ttfunk (1.0.3) - tzinfo (1.2.11) - thread_safe (~> 0.1) - tzinfo-data (1.2022.1) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + tzinfo-data (1.2023.4) tzinfo (>= 1.0.0) - unicode-display_width (2.2.0) + unicode-display_width (2.5.0) + uri (0.10.3) + version_gem (1.1.3) warden (1.2.9) rack (>= 2.0.9) - webdrivers (5.2.0) - nokogiri (~> 1.6) - rubyzip (>= 1.3.0) - selenium-webdriver (~> 4.0) - webmock (3.14.0) + webmock (3.19.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webrick (1.7.0) - websocket (1.2.9) - websocket-driver (0.7.5) + webrick (1.8.1) + websocket (1.2.10) + websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - yard (0.9.28) - webrick (~> 1.7.0) - zeitwerk (2.6.8) + yard (0.9.34) + zeitwerk (2.6.12) PLATFORMS arm64-darwin-21 arm64-darwin-22 + arm64-darwin-23 x86_64-darwin-19 x86_64-darwin-20 + x86_64-darwin-21 + x86_64-darwin-23 x86_64-linux DEPENDENCIES @@ -456,7 +488,7 @@ DEPENDENCIES erb_lint exception_notification (>= 4.1.0) factory_bot_rails - fomantic-ui-sass + fomantic-ui-sass (= 2.8.8.1) httparty jbuilder (>= 2.0) jquery-rails @@ -470,7 +502,8 @@ DEPENDENCIES mini_racer (~> 0.6.3) moment_timezone-rails momentjs-rails (>= 2.9.0) - mysql2 (~> 0.4.10) + mysql2 (~> 0.5) + net-http net-ldap newrelic_rpm oauth2 @@ -485,7 +518,7 @@ DEPENDENCIES rack-attack rack-ssl-enforcer rack-test - rails (= 6.0.5) + rails (= 6.1.7.6) rake (>= 10.3.2) rspec-rails (>= 3.5.0) rubocop @@ -493,7 +526,7 @@ DEPENDENCIES rubyzip sass-rails (>= 4.0.3) sdoc (>= 0.4.0) - selenium-webdriver (>= 4.7.1) + selenium-webdriver (>= 4.16) slack-notifier spring sprockets-rails (>= 3.2.1) @@ -501,12 +534,13 @@ DEPENDENCIES terser (>= 1.1.7) thin tzinfo-data - webdrivers + uri (= 0.10.3) webmock + webrick (~> 1.8) yard RUBY VERSION - ruby 2.7.7p221 + ruby 3.2.2p53 BUNDLED WITH - 2.4.14 + 2.5.3 diff --git a/README.md b/README.md index 60042da1c..8e46ad65f 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ We released new documentation! Check it out [here](https://docs.autolabproject.c 3. Create necessary directories. ``` - mkdir attachments/ tmp/ + mkdir tmp/ ``` ### Running Tests diff --git a/app/assets/javascripts/annotations.js b/app/assets/javascripts/annotations.js index b636bed9d..ac526c14b 100644 --- a/app/assets/javascripts/annotations.js +++ b/app/assets/javascripts/annotations.js @@ -510,7 +510,6 @@ function elt(t, a) { function createAnnotation() { var annObj = { filename: fileNameStr, - submitted_by: cudEmailStr, }; if (currentHeaderPos || currentHeaderPos === 0) { diff --git a/app/assets/javascripts/base64.js b/app/assets/javascripts/base64.js index a39a02708..c3a7d54ba 100644 --- a/app/assets/javascripts/base64.js +++ b/app/assets/javascripts/base64.js @@ -1,125 +1,9 @@ function strToBase64(str) { - return base64EncArr(strToUTF8Arr(str)); + return bytesToBase64(new TextEncoder().encode(str)); } -// https://developer.mozilla.org/en-US/docs/Glossary/Base64#solution_2_%E2%80%93_rewriting_atob_and_btoa_using_typedarrays_and_utf-8 -function uint6ToB64(nUint6) { - return nUint6 < 26 - ? nUint6 + 65 - : nUint6 < 52 - ? nUint6 + 71 - : nUint6 < 62 - ? nUint6 - 4 - : nUint6 === 62 - ? 43 - : nUint6 === 63 - ? 47 - : 65; +// https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem +function bytesToBase64(bytes) { + const binString = String.fromCodePoint(...bytes); + return btoa(binString); } - -function base64EncArr(aBytes) { - let nMod3 = 2; - let sB64Enc = ""; - - const nLen = aBytes.length; - let nUint24 = 0; - for (let nIdx = 0; nIdx < nLen; nIdx++) { - nMod3 = nIdx % 3; - if (nIdx > 0 && ((nIdx * 4) / 3) % 76 === 0) { - sB64Enc += "\r\n"; - } - - nUint24 |= aBytes[nIdx] << ((16 >>> nMod3) & 24); - if (nMod3 === 2 || aBytes.length - nIdx === 1) { - sB64Enc += String.fromCodePoint( - uint6ToB64((nUint24 >>> 18) & 63), - uint6ToB64((nUint24 >>> 12) & 63), - uint6ToB64((nUint24 >>> 6) & 63), - uint6ToB64(nUint24 & 63) - ); - nUint24 = 0; - } - } - return ( - sB64Enc.substring(0, sB64Enc.length - 2 + nMod3) + - (nMod3 === 2 ? "" : nMod3 === 1 ? "=" : "==") - ); -} - -function strToUTF8Arr(sDOMStr) { - let aBytes; - let nChr; - const nStrLen = sDOMStr.length; - let nArrLen = 0; - - /* mapping… */ - for (let nMapIdx = 0; nMapIdx < nStrLen; nMapIdx++) { - nChr = sDOMStr.codePointAt(nMapIdx); - - if (nChr > 65536) { - nMapIdx++; - } - - nArrLen += - nChr < 0x80 - ? 1 - : nChr < 0x800 - ? 2 - : nChr < 0x10000 - ? 3 - : nChr < 0x200000 - ? 4 - : nChr < 0x4000000 - ? 5 - : 6; - } - - aBytes = new Uint8Array(nArrLen); - - /* transcription… */ - let nIdx = 0; - let nChrIdx = 0; - while (nIdx < nArrLen) { - nChr = sDOMStr.codePointAt(nChrIdx); - if (nChr < 128) { - /* one byte */ - aBytes[nIdx++] = nChr; - } else if (nChr < 0x800) { - /* two bytes */ - aBytes[nIdx++] = 192 + (nChr >>> 6); - aBytes[nIdx++] = 128 + (nChr & 63); - } else if (nChr < 0x10000) { - /* three bytes */ - aBytes[nIdx++] = 224 + (nChr >>> 12); - aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63); - aBytes[nIdx++] = 128 + (nChr & 63); - } else if (nChr < 0x200000) { - /* four bytes */ - aBytes[nIdx++] = 240 + (nChr >>> 18); - aBytes[nIdx++] = 128 + ((nChr >>> 12) & 63); - aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63); - aBytes[nIdx++] = 128 + (nChr & 63); - nChrIdx++; - } else if (nChr < 0x4000000) { - /* five bytes */ - aBytes[nIdx++] = 248 + (nChr >>> 24); - aBytes[nIdx++] = 128 + ((nChr >>> 18) & 63); - aBytes[nIdx++] = 128 + ((nChr >>> 12) & 63); - aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63); - aBytes[nIdx++] = 128 + (nChr & 63); - nChrIdx++; - } /* if (nChr <= 0x7fffffff) */ else { - /* six bytes */ - aBytes[nIdx++] = 252 + (nChr >>> 30); - aBytes[nIdx++] = 128 + ((nChr >>> 24) & 63); - aBytes[nIdx++] = 128 + ((nChr >>> 18) & 63); - aBytes[nIdx++] = 128 + ((nChr >>> 12) & 63); - aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63); - aBytes[nIdx++] = 128 + (nChr & 63); - nChrIdx++; - } - nChrIdx++; - } - - return aBytes; -} \ No newline at end of file diff --git a/app/assets/javascripts/collapsible.js b/app/assets/javascripts/collapsible.js new file mode 100644 index 000000000..3947e704b --- /dev/null +++ b/app/assets/javascripts/collapsible.js @@ -0,0 +1,19 @@ +$(document).ready(function(){ + const $collapsible = $('.collapsible'); + $collapsible.collapsible({ accordion: false }); // Multiple items can be open at once + + // Accessibility features + const $menuLink = $collapsible.find('.collapsible-menu-link'); + $menuLink.attr('aria-expanded', false); + $menuLink.attr('role', 'button'); + $menuLink.on('click keydown', function() { + $(this).attr("aria-expanded", function(_, attr){ + return attr !== "true"; + }); + }); + + // Expand first item of first collapsible + const $firstCollapsible = $collapsible.first(); + $firstCollapsible.collapsible('open', 0); + $firstCollapsible.find('.collapsible-menu-link:first').attr('aria-expanded', 'true'); +}); diff --git a/app/assets/javascripts/dropdown.js b/app/assets/javascripts/dropdown.js new file mode 100644 index 000000000..ef7b0e52f --- /dev/null +++ b/app/assets/javascripts/dropdown.js @@ -0,0 +1,18 @@ +function toggleOptions(dropdown, table) { + const $dropdown = $(dropdown); + const $table = $(table); + + if ($dropdown.length === 0 || $table.length === 0) { + console.error('Invalid dropdown or table selector provided to toggleOptions'); + return; + } + + $table.toggle(); + if ($table.is(':hidden')) { + $dropdown.find('.expand-more').show(); + $dropdown.find('.expand-less').hide(); + } else { + $dropdown.find('.expand-more').hide(); + $dropdown.find('.expand-less').show(); + } +} diff --git a/app/assets/javascripts/edit_assessment.js b/app/assets/javascripts/edit_assessment.js index fa3a122d5..76994a33e 100644 --- a/app/assets/javascripts/edit_assessment.js +++ b/app/assets/javascripts/edit_assessment.js @@ -32,6 +32,17 @@ changes = false; }); + $('#assessment_config_file').on('change', function () { + var fileSelector = $("#assessment_config_file").get(0); + var file = fileSelector.files[0]; + + if (!file?.name?.endsWith('.rb')) { + $('#config-file-type-incorrect').text(`Warning: ${file.name} doesn't match expected .rb file type`) + } else { + $('#config-file-type-incorrect').text("") + } + }) + $('input[name="assessment[is_positive_grading]"]').on('change', function() { if(has_annotations){ if ($(this).prop('checked') != is_positive_grading) { @@ -43,6 +54,80 @@ } }); + // Penalties tab + let initial_load = true; // determines if the page is loading for the first time, if so, don't clear the fields + + function unlimited_submissions_callback() { + const checked = $(this).prop('checked'); + const $max_submissions = $('#assessment_max_submissions'); + $max_submissions.prop('disabled', checked); + if (checked) { + $max_submissions.val('Unlimited submissions'); + } else if (!initial_load) { + $max_submissions.val(''); + } + } + $('#unlimited_submissions').on('change', unlimited_submissions_callback); + + function unlimited_grace_days_callback() { + const checked = $(this).prop('checked'); + const $max_grace_days = $('#assessment_max_grace_days'); + $max_grace_days.prop('disabled', checked); + if (checked) { + $max_grace_days.val('Unlimited grace days'); + } else if (!initial_load) { + $max_grace_days.val(''); + } + } + $('#unlimited_grace_days').on('change', unlimited_grace_days_callback); + + function use_default_late_penalty_callback() { + const checked = $(this).prop('checked'); + const $latePenaltyValue = $('#assessment_late_penalty_attributes_value'); + const $latePenaltyField = $latePenaltyValue.parent(); + $latePenaltyField.find('input').prop('disabled', checked); + $latePenaltyField.find('select').prop('disabled', checked); + if (checked) { + $latePenaltyValue.val('Course default'); + } else if (!initial_load) { + $latePenaltyValue.val(''); + } + } + $('#use_default_late_penalty').on('change', use_default_late_penalty_callback); + + function use_default_version_threshold_callback() { + const checked = $(this).prop('checked'); + const $version_threshold = $('#assessment_version_threshold'); + $version_threshold.prop('disabled', checked); + if (checked) { + $version_threshold.val('Course default'); + } else if (!initial_load) { + $version_threshold.val(''); + } + } + $('#use_default_version_threshold').on('change', use_default_version_threshold_callback); + + function use_default_version_penalty_callback() { + const checked = $(this).prop('checked'); + const $versionPenaltyValue = $('#assessment_version_penalty_attributes_value'); + const $versionPenaltyField = $versionPenaltyValue.parent(); + $versionPenaltyField.find('input').prop('disabled', checked); + $versionPenaltyField.find('select').prop('disabled', checked); + if (checked) { + $versionPenaltyValue.val('Course default'); + } else if (!initial_load) { + $versionPenaltyValue.val(''); + } + } + $('#use_default_version_penalty').on('change', use_default_version_penalty_callback); + + // Trigger on page load + unlimited_submissions_callback.call($('#unlimited_submissions')); + unlimited_grace_days_callback.call($('#unlimited_grace_days')); + use_default_late_penalty_callback.call($('#use_default_late_penalty')); + use_default_version_threshold_callback.call($('#use_default_version_threshold')); + use_default_version_penalty_callback.call($('#use_default_version_penalty')); + initial_load = false; }); })(); diff --git a/app/assets/javascripts/gradebook.js b/app/assets/javascripts/gradebook.js index efc52ba45..f2eddf2c6 100755 --- a/app/assets/javascripts/gradebook.js +++ b/app/assets/javascripts/gradebook.js @@ -20,6 +20,7 @@ var slickgrid_options = { submission_status_key = columnDef.field + "_submission_status" grade_type_key = columnDef.field + "_grade_type" end_at_key = columnDef.field + "_end_at" + history_key = columnDef.field + "_history_url"; switch (data[grade_type_key]) { case "excused": @@ -39,37 +40,22 @@ var slickgrid_options = { case "normal": switch (data[submission_status_key]) { case "submitted": - if (columnDef.before_grading_deadline) { - tip = 'Grading is in-progress.
' - tip += 'Final scores will be visible here after the grading deadline (specified as an assessment property).
' - tip += 'Meanwhile, check the assessment gradesheet for updates.
'; - value = ''; - } break; case "not_submitted": - if (columnDef.before_grading_deadline) { - value = ""; - } else { - tip = user + ' has not made any submissions for ' + asmt + '.
'; - tip += 'The last date for submission by ' + user + ' was ' + data[end_at_key] + '.'; - value = ''; - } + tip = user + ' has not made any submissions for ' + asmt + '.
'; + tip += 'The last date for submission by ' + user + ' was ' + data[end_at_key] + '.'; + value = ''; break; case "not_yet_submitted": - if (columnDef.before_grading_deadline) { - value = ""; - } else { - tip = user + ' has not yet made any submissions for ' + asmt + '. '; - tip += 'The last date for submission by ' + user + ' is ' + data[end_at_key] + '.'; - value = ''; - } + tip = user + ' has not yet made any submissions for ' + asmt + '.
'; + tip += 'The last date for submission by ' + user + ' is ' + data[end_at_key] + '.'; + value = ''; break; } } - - return (value !== null) ? value : "–"; + return (value !== null) ? ((data[history_key] !== undefined) ? link_to(data[history_key], value) : value) : "–"; } }; @@ -117,7 +103,6 @@ $(function () { // column header tooltips for (var i = 0; i < columns.length; i++) { - columns[i].headerCssClass = "tip"; columns[i].toolTip = columns[i].name; } @@ -164,18 +149,13 @@ $(function () { }); $(window).resize(); - grid.onMouseEnter.subscribe(function(e, args) { - $('.tooltipped', e.target).tooltip({ - position: 'top', - delay: 100, - html: true - }); - }); - - $('.tooltipped').tooltip({ + const tooltipOpts = { position: 'top', delay: 100, html: true + }; + grid.onMouseEnter.subscribe(function(e, args) { + // Since Materialize's tooltip method was overwritten by jquery-ui + M.Tooltip.init(document.querySelectorAll(".tooltipped"), tooltipOpts); }); - }) diff --git a/app/assets/javascripts/gradesheet.js.erb b/app/assets/javascripts/gradesheet.js.erb index d899b3d53..5d0173cff 100755 --- a/app/assets/javascripts/gradesheet.js.erb +++ b/app/assets/javascripts/gradesheet.js.erb @@ -76,7 +76,7 @@ jQuery(function() { // add necessary classes and data to rows after creation but before rendering function completeRow(row, data, index) { var submission = aux_data[index]; - row.className = submission['r_class']; + row.className = submission?.r_class; // set up name & email column $td_name = $('.id', row); @@ -89,6 +89,7 @@ jQuery(function() { } $name_col.children().appendTo($td_name); + if (submission['submission-id'] == null) return; // set up problem columns $problems = jQuery('.problem',row); for (var i = 0; i < $problems.length; i++) { @@ -126,7 +127,7 @@ jQuery(function() { 'iDisplayStart': 0, 'deferRender': true, 'aoColumnDefs': [ - { "bSortable": false, "aTargets": [ 0,4 ] }, + { "bSortable": false, "aTargets": [ 0 ] }, { "bSearchable": false, "aTargets": non_searchable_columns }, { "sType": "html", "aTargets": [ email_col,lec_sec_col ] }, { "sType": "num-html", "aTargets": numeric_columns }, @@ -155,6 +156,7 @@ jQuery(function() { add_icons('th.id'); add_icons('th.course_number'); add_icons('th.lec-sec'); + add_icons('th.actions'); add_icons('th.problem'); add_icons('th.late_penalty'); add_icons('th.total'); diff --git a/app/assets/javascripts/init_handin_datetimepickers.js b/app/assets/javascripts/init_handin_datetimepickers.js index 3aea194a5..31521cb49 100644 --- a/app/assets/javascripts/init_handin_datetimepickers.js +++ b/app/assets/javascripts/init_handin_datetimepickers.js @@ -20,27 +20,12 @@ $(document).ready(function() { /* Invoke flatpickr library */ return flatpickr(selector, defaults) } - - /* Create all 4 date pickers */ - var grading_deadline_pickr = createDatePicker('#assessment_grading_deadline'); - - function endAtOnCloseHandler(selected_dates, date_str, flatpickr_inst) { - var cur_date = selected_dates[0]; - - if (grading_deadline_pickr.selectedDates[0].getTime() < cur_date.getTime()) { - grading_deadline_pickr.setDate(cur_date, true); - } - } /* Add custom onClose handler for end at date picker */ - var end_at_pickr = createDatePicker('#assessment_end_at',{onClose:endAtOnCloseHandler}); + var end_at_pickr = createDatePicker('#assessment_end_at'); function dueAtOnCloseHandler(selected_dates, date_str, flatpickr_inst) { var cur_date = selected_dates[0]; - - if (grading_deadline_pickr.selectedDates[0].getTime() < cur_date.getTime()) { - grading_deadline_pickr.setDate(cur_date, true); - } if (end_at_pickr.selectedDates[0].getTime() < cur_date.getTime()) { end_at_pickr.setDate(cur_date, true); @@ -57,10 +42,6 @@ $(document).ready(function() { due_at_pickr.setDate(cur_date, true); } - if (grading_deadline_pickr.selectedDates[0].getTime() < cur_date.getTime()) { - grading_deadline_pickr.setDate(cur_date, true); - } - if (end_at_pickr.selectedDates[0].getTime() < cur_date.getTime()) { end_at_pickr.setDate(cur_date, true); } diff --git a/app/assets/javascripts/moss.js b/app/assets/javascripts/moss.js new file mode 100644 index 000000000..e1ea3cb0d --- /dev/null +++ b/app/assets/javascripts/moss.js @@ -0,0 +1,18 @@ +function filterCourses(name) { + $(".filterableCourse").each(function(i, e) { + const keywords = name.trim().split(" "); + const courseName = e.id.toLowerCase(); + let show = keywords.some((k) => { + return courseName.includes(k); + }); + $(e).toggle(show); + }); +} + +$(document).ready(function() { + const $courseFilter = $("#courseFilter"); + $courseFilter.on("keyup", function() { + filterCourses(this.value.toLowerCase()); + }); + $courseFilter.trigger("keyup"); +}); diff --git a/app/assets/stylesheets/annotations.scss b/app/assets/stylesheets/annotations.scss index 048abb463..82f7cad3b 100755 --- a/app/assets/stylesheets/annotations.scss +++ b/app/assets/stylesheets/annotations.scss @@ -893,6 +893,14 @@ font-weight: bold; } +/* Disable Bootstrap styling to allow for scrolling within annotations and + * grades sidebars in file viewer + * (allows clicking on the scrollbar) + */ +.drag-target.right-aligned { + display: none; +} + /* Tooltip */ span > .material-icons { vertical-align: middle; diff --git a/app/assets/stylesheets/assessments.scss b/app/assets/stylesheets/assessments.scss new file mode 100644 index 000000000..3fa89c6c7 --- /dev/null +++ b/app/assets/stylesheets/assessments.scss @@ -0,0 +1,26 @@ +@import 'variables'; + +.badge { + &.blue { + margin-left: 0 !important; + } +} + +.card-title { + margin-bottom: 0 !important; +} + +.date .collection-item { + padding-left: 24px !important; + padding-right: 24px !important; +} + +.date .collection-item p { + line-height: 1rem; +} + +.date p { + margin: 0; + font-size: 0.85rem; + color: #676464; +} diff --git a/app/assets/stylesheets/datatable.adapter.css b/app/assets/stylesheets/datatable.adapter.css index 414607c28..53292068c 100755 --- a/app/assets/stylesheets/datatable.adapter.css +++ b/app/assets/stylesheets/datatable.adapter.css @@ -32,6 +32,14 @@ table.dataTable { clear: both; } +a.paginate_button, +a.paginate_active { + display: inline-block; + padding: 2px 4px; + margin-left: 2px; + cursor: pointer; + cursor: hand; +} th { z-index: 1; position: sticky; diff --git a/app/assets/stylesheets/gradesheet.css.scss b/app/assets/stylesheets/gradesheet.css.scss index 8b1bab35e..00b0cc52d 100755 --- a/app/assets/stylesheets/gradesheet.css.scss +++ b/app/assets/stylesheets/gradesheet.css.scss @@ -96,7 +96,7 @@ td.focus { color: #555; } -#grades td.edit { +#grades td.edit, #grades td.problem { text-align: center; background-color: #f2f2f2; } diff --git a/app/assets/stylesheets/instructor_gradebook.scss b/app/assets/stylesheets/instructor_gradebook.scss index a33d169d6..83e667367 100755 --- a/app/assets/stylesheets/instructor_gradebook.scss +++ b/app/assets/stylesheets/instructor_gradebook.scss @@ -10,6 +10,7 @@ div#pageBody { margin: 0 auto; padding: 15px; box-sizing: border-box; + max-width: 100%; } h1#courseTitle { @@ -171,16 +172,6 @@ div#footer { font-weight: bold; } -#gradebook .slick-cell.assessment_final_score { - background: white; -} - -#gradebook .slick-header-column.assessment_final_score { - background: #f3f3f3; - border-right: 1px solid #eaeaea; - color: #999; -} - #gradebook .slick-cell.computed { color: #999; } diff --git a/app/assets/stylesheets/style.css.scss b/app/assets/stylesheets/style.css.scss index d61dafd52..69ce4dab7 100644 --- a/app/assets/stylesheets/style.css.scss +++ b/app/assets/stylesheets/style.css.scss @@ -237,7 +237,7 @@ div.main-header img { } /* Custom Form Styling */ -.input-field, +.input-field:not(.no-padding-bottom), form > div { padding-bottom: 1rem !important; } @@ -645,12 +645,13 @@ p.attention { } ul.moss-list { list-style: none; - background: $autolab-subtle-gray; border-radius: 5px; } ul.moss-list > li { cursor: pointer; + background: $autolab-subtle-gray; padding: 15px; + margin-bottom: 15px; } ul.moss-list > li > h5 { font-weight: bold; @@ -948,7 +949,12 @@ body.autolab { .h3, .h4, .h5, - .h6 { + .h6, + button, + input, + optgroup, + select, + textarea { font-family: "Source Sans Pro", sans-serif; } @@ -989,7 +995,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; } @@ -1527,6 +1533,11 @@ table.sub td, th { color: white !important; } +.error-header { + color: $autolab-red; +} + + /* Manage Submissions */ .btn.submissions-main { diff --git a/app/controllers/annotations_controller.rb b/app/controllers/annotations_controller.rb index b5efc8963..03372fa01 100755 --- a/app/controllers/annotations_controller.rb +++ b/app/controllers/annotations_controller.rb @@ -75,6 +75,8 @@ def annotation_params params[:annotation].delete(:id) params[:annotation].delete(:created_at) params[:annotation].delete(:updated_at) + # Prevent spoofing the submitter + 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) diff --git a/app/controllers/api/v1/assessments_controller.rb b/app/controllers/api/v1/assessments_controller.rb index 63dd46f5e..1d1959a2a 100644 --- a/app/controllers/api/v1/assessments_controller.rb +++ b/app/controllers/api/v1/assessments_controller.rb @@ -1,3 +1,5 @@ +require "archive" + class Api::V1::AssessmentsController < Api::V1::BaseApiController include AssessmentHandinCore @@ -14,8 +16,6 @@ def index allowed = [:name, :display_name, :start_at, :due_at, :end_at, :category_name] if @cud.student? asmts = asmts.released - else - allowed += [:grading_deadline] end respond_with asmts, only: allowed @@ -24,9 +24,6 @@ def index def show allowed = [:name, :display_name, :description, :start_at, :due_at, :end_at, :updated_at, :max_grace_days, :max_submissions, :disable_handins, :category_name, :group_size, :writeup_format, :handout_format, :has_scoreboard, :has_autograder, :max_unpenalized_submissions] - if not @cud.student? - allowed += [:grading_deadline] - end result = @assessment.attributes.symbolize_keys result.merge!(:has_scoreboard => @assessment.has_scoreboard?) @@ -57,6 +54,7 @@ def writeup end if @assessment.writeup_is_file? + # Note: writeup_is_file? validates that the writeup lies within the assessment folder filename = @assessment.writeup_path send_file(filename, disposition: "inline", @@ -73,6 +71,11 @@ def handout if @assessment.overwrites_method?(:handout) hash = @assessment.config_module.handout + # Ensure that handout lies within the assessment folder + unless Archive.in_dir?(Pathname(hash["fullpath"]), @assessment.folder_path) + respond_with_hash({:handout => "none"}) and return + end + send_file(hash["fullpath"], disposition: "inline", filename: hash["filename"]) @@ -84,6 +87,7 @@ def handout end if @assessment.handout_is_file? + # Note: handout_is_file? validates that the handout lies within the assessment folder filename = @assessment.handout_path send_file(filename, disposition: "inline", diff --git a/app/controllers/api/v1/groups_controller.rb b/app/controllers/api/v1/groups_controller.rb index ec553ef50..02b8840e6 100644 --- a/app/controllers/api/v1/groups_controller.rb +++ b/app/controllers/api/v1/groups_controller.rb @@ -5,14 +5,39 @@ class Api::V1::GroupsController < Api::V1::BaseApiController # endpoint to obtain all groups def index - groups = @assessment.groups + show_members = params[:show_members].to_boolean + groups = @assessment.groups(show_members: show_members) + + if show_members + groups_json = [] + groups.each do |group| + group_json = get_group_json(group) + groups_json << group_json + end + else + groups_json = groups.as_json + end + group_size = @assessment.group_size respond_with({ group_size: group_size, - groups: groups, + groups: groups_json, assessment: @assessment }) end + def show + require_params([:id]) + group = @assessment.groups(show_members: true).find_by(id: params[:id]) + + if group.nil? + raise ApiError.new("Couldn't find group with id #{params[:id]}", :bad_request) + end + + group_json = get_group_json(group) + + respond_with group_json + end + # create group endpoint def create require_params([:groups]) @@ -72,4 +97,18 @@ def destroy end respond_with_hash(message: "Group #{params[:id]} successfully deleted") end + +private + + def get_group_json(group) + members = [] + group.assessment_user_data.each do |assessment_user_datum| + user_json = assessment_user_datum.course_user_datum.user.as_json + user_json[:course_user_datum_id] = assessment_user_datum.course_user_datum_id + members << user_json + end + + group_json = group.as_json + group_json[:members] = members + end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 0fc224846..aec60c513 100755 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -220,12 +220,12 @@ def set_assessment @assessment = @course.assessments.find_by!(name: params[:assessment_name] || params[:name]) rescue StandardError flash[:error] = "The assessment was not found for this course." - redirect_to(action: :index) && return + redirect_to(course_assessments_path(@course)) && return end if @cud.student? && !@assessment.released? flash[:error] = "You are not authorized to view this assessment." - redirect_to(action: :index) && return + redirect_to(course_assessments_path(@course)) && return end @breadcrumbs << (view_context.current_assessment_link) diff --git a/app/controllers/assessment/autograde.rb b/app/controllers/assessment/autograde.rb index f25d2ad03..c3e047e84 100644 --- a/app/controllers/assessment/autograde.rb +++ b/app/controllers/assessment/autograde.rb @@ -28,7 +28,11 @@ def autograde_done extend_config_module(@assessment, submissions[0], @cud) - require_relative(Rails.root.join("assessmentConfig", "#{@course.name}-#{@assessment.name}.rb")) + if (@assessment.use_unique_module_name) + require_relative(@assessment.unique_config_file_path) + else + require_relative(Rails.root.join("assessmentConfig", "#{@course.name}-#{@assessment.name}.rb")) + end if @assessment.overwrites_method?(:autogradeDone) @assessment.config_module.autogradeDone(submissions, feedback_str) diff --git a/app/controllers/assessment/grading.rb b/app/controllers/assessment/grading.rb index 6a3825ad9..6829d0a26 100755 --- a/app/controllers/assessment/grading.rb +++ b/app/controllers/assessment/grading.rb @@ -1,532 +1,535 @@ -require "csv" -require "utilities" - -module AssessmentGrading - # Export all scores for an assessment for all students as CSV - def bulkExport - # generate CSV - csv = render_to_string layout: false - - # send CSV file - timestamp = Time.now.strftime "%Y%m%d%H%M" - file_name = "#{@course.name}_#{@assessment.name}_#{timestamp}.csv" - send_data csv, filename: file_name - end - - # Allows the user to upload multiple scores or comments from a CSV file - def bulkGrade - return unless request.post? - - # part 1: submitting a CSV for processing and returning errors in CSV - if params[:upload] - # get data type - @data_type = params[:upload][:data_type].to_sym - unless @data_type == :scores || @data_type == :feedback - flash[:error] = "bulkGrade: invalid data_type received from client" - redirect_to(action: :bulkGrade) && return - end - - # get CSV - csv_file = params[:upload][:file] - if csv_file - @csv = csv_file.read - else - flash[:error] = "You need to choose a CSV file to upload." - redirect_to(action: :bulkGrade) && return - end - - # process CSV - success, entries = parse_csv @csv, @data_type - if success - @entries = entries - @valid_entries = valid_entries? entries - else - redirect_to(action: :bulkGrade) && return - end - end - end - - # part 2: confirming a CSV upload and saving data - def bulkGrade_complete - redirect_to(action: :bulkGrade) && return unless request.post? - - # retrieve entries CSV from hidden field in form - csv = params[:confirm][:bulkGrade_csv] - data_type = params[:confirm][:bulkGrade_data_type].to_sym - unless csv && data_type - flash[:error] = "Please try again." - redirect_to(action: :bulkGrade) && return - end - - success, @entries = parse_csv csv, data_type - if !success - flash[:error] = "bulkGrade_complete: invalid csv returned from client" - redirect_to(action: :bulkGrade) && return - elsif !valid_entries?(@entries) - flash[:error] = "bulkGrade_complete: invalid entries returned from client" - redirect_to(action: :bulkGrade) && return - end - - # save data - unless save_entries @entries, data_type - flash[:error] = "Failed to Save Entries" - redirect_to(action: :bulkGrade) && return - end - end - -private - - def valid_entries?(entries) - entries.reduce true do |acc, entry| - acc && valid_entry?(entry) - end - end - - def valid_entry?(entry) - entry.values.reduce true do |acc, v| - acc && (case v - when Hash - !v.include?(:error) && valid_entry?(v) - else - true - end) - end - end - - def save_entries(entries, data_type) - asmt = @assessment - - begin - User.transaction do - entries.each do |entry| - user = CourseUserDatum.joins(:user) - .find_by(users: { email: entry[:email] }, course: asmt.course) - - aud = AssessmentUserDatum.get asmt.id, user.id - if entry[:grade_type] - aud.grade_type = AssessmentUserDatum::GRADE_TYPE_MAP[entry[:grade_type]] - aud.save! - end - - unless sub = aud.latest_submission - sub = asmt.submissions.create!( - course_user_datum_id: user.id, - assessment_id: asmt.id, - submitted_by_id: @cud.id, - created_at: [Time.current, asmt.due_at].min - ) - end - - entry[:data].each do |problem_name, datum| - next unless datum - - problem = asmt.problems.find_by_name problem_name - - score = sub.scores.find_by_problem_id problem.id - unless score - score = sub.scores.new( - grader_id: @cud.id, - problem_id: problem.id - ) - end - - case data_type - when :scores - score.score = datum - when :feedback - score.feedback = datum.gsub("\\n", "\n").gsub("\\t", "\t") - end - - updateScore user.id, score - end - end # entries.each - end # User.transaction - - true - rescue => e - flash[:error] = "An error occurred: #{e}" - - false - end - end - - def parse_csv(csv, data_type) - # inputs for parse_csv_row - problems = @assessment.problems - emails = Set.new(CourseUserDatum.joins(:user).where(course: @assessment.course).map &:email) - - # process CSV - entries = [] - begin - CSV.parse(csv, skip_blanks: true) do |row| - entries << parse_csv_row(row, data_type, problems, emails) - end - rescue CSV::MalformedCSVError => e - flash[:error] = "Failed to parse CSV -- make sure the grades " \ - "are formatted correctly:
#{e}
" - flash[:html_safe] = true - return false, [] - end - - [true, entries] - end - - def parse_csv_row(row, kind, problems, emails) - row = row.dup - - email = row.shift.to_s - data = row.shift problems.count - grade_type = row.shift.to_s - - # to be returned - processed = {} - processed[:extra_cells] = row if row.length > 0 # currently unused - - # Checking that emails are valid - processed[:email] = if email.blank? - { error: nil } - elsif emails.include? email - email - else - { error: email } - end - - # data - data.map! do |datum| - if datum.blank? - nil - else - case kind - when :scores - Float(datum) rescue({ error: datum }) - when :feedback - datum - end - end - end - - # pad data with nil until there are problems.count elements - data.fill nil, data.length, problems.count - data.length - - processed[:data] = {} - problems.each_with_index { |problem, i| processed[:data][problem.name] = data[i] } - - # grade type - processed[:grade_type] = if grade_type.blank? - nil - elsif AssessmentUserDatum::GRADE_TYPE_MAP.key? grade_type.to_sym - grade_type.to_sym - else - { error: grade_type } - end - - processed - end - -public - - def quickSetScore - return unless request.post? - return unless params[:submission_id] - return unless params[:problem_id] - - # get submission and problem IDs - sub_id = params[:submission_id].to_i - prob_id = params[:problem_id].to_i - - # find existing score for this problem, if there's one - # otherwise, create it - score = Score.find_or_initialize_by_submission_id_and_problem_id(sub_id, prob_id) - - score.grader_id = @cud.id - score.score = params[:score].to_f - - updateScore(score.submission.course_user_datum_id, score) - - render plain: score.score - - # see http://stackoverflow.com/questions/6163125/duplicate-records-created-by-find-or-create-by - # and http://barelyenough.org/blog/2007/11/activerecord-race-conditions/ - # and http://stackoverflow.com/questions/5917355/find-or-create-race-conditions - rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordInvalid => error - @retries_left ||= 2 - retry unless (@retries_left -= 1) < 0 - raise error - end - - def quickSetScoreDetails - return unless request.post? - return unless params[:submission_id] - return unless params[:problem_id] - # get submission and problem IDs - sub_id = params[:submission_id].to_i - prob_id = params[:problem_id].to_i - - - # find existing score for this problem, if there's one - # otherwise, create it - score = Score.find_or_initialize_by_submission_id_and_problem_id(sub_id, prob_id) - - score.grader_id = @cud.id - score.feedback = params[:feedback] - score.released = params[:released] - - updateScore(score.submission.course_user_datum_id, score) - - render plain: score.id - - # see http://stackoverflow.com/questions/6163125/duplicate-records-created-by-find-or-create-by - # and http://barelyenough.org/blog/2007/11/activerecord-race-conditions/ - # and http://stackoverflow.com/questions/5917355/find-or-create-race-conditions -rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordInvalid => error - @retries_left ||= 2 - retry unless (@retries_left -= 1) < 0 - raise error -end - - def submission_popover - render partial: "popover", locals: { s: Submission.find(params[:submission_id].to_i) } - end - - def score_grader_info - score = Score.find(params[:score_id]) - grader = (if score then score.grader else nil end) - grader_info = "" - if grader - grader_info = grader.full_name_with_email - end - - feedback = score.feedback - response = { "grader" => grader_info, "feedback" => feedback, "score" => score.score } - render json: response - end - - def viewGradesheet - load_gradesheet_data - end - - def quickGetTotal - return unless params[:submission_id] - - # get submission and problem IDs - sub_id = params[:submission_id].to_i - - render plain: Submission.find(sub_id).final_score(@cud) - end - - def statistics - return unless load_course_config - latest_submissions = @assessment.submissions.latest_for_statistics.includes(:scores, :course_user_datum) - #latest_submissions = @assessment.submissions.latest.includes(:scores, :course_user_datum) - - # Each value other than for :all is of the form - # [[, {:mean, :median, :max, :min, :stddev}]...] - # for each group. :all has just the hash. - @statistics = {} - @scores = {} - # Rather than special case this, we just index into the result. - by_assessment = latest_submissions.group_by { |s| s.assessment.name } - assessment_stats = stats_for_grouping(by_assessment) - all_grouping = assessment_stats[assessment_stats.keys[0]] - - if all_grouping.nil? - @statistics[:all] = [] - @scores[:all] = [] - else - @statistics[:all] = all_grouping[:data] - @scores[:all] = all_grouping[1] - end - - by_course_number = latest_submissions.group_by { |s| s.course_user_datum.course_number } - @statistics[:course_number] = stats_for_grouping(by_course_number) - @scores[:course_number] = scores_for_grouping(by_course_number) - - by_lecture = latest_submissions.group_by { |s| s.course_user_datum.lecture } - @statistics[:lecture] = stats_for_grouping(by_lecture) - @scores[:lecture] = scores_for_grouping(by_lecture) - - by_section = latest_submissions.group_by { |s| s.course_user_datum.section } - @statistics[:section] = stats_for_grouping(by_section) - @scores[:section] = scores_for_grouping(by_section) - - by_school = latest_submissions.group_by { |s| s.course_user_datum.school } - @statistics[:school] = stats_for_grouping(by_school) - @scores[:school] = scores_for_grouping(by_school) - - by_major = latest_submissions.group_by { |s| s.course_user_datum.major } - @statistics[:major] = stats_for_grouping(by_major) - @scores[:major] = scores_for_grouping(by_major) - - by_year = latest_submissions.group_by { |s| s.course_user_datum.year } - @statistics[:year] = stats_for_grouping(by_year) - @scores[:year] = scores_for_grouping(by_year) - @statistics[:grader] = stats_for_grader(latest_submissions) - end - -private - - def load_course_config - course = @course.name.gsub(/[^A-Za-z0-9]/, "") - begin - load(File.join(Rails.root, "courseConfig", - "#{course}.rb")) - eval("extend(Course#{course.camelize})") - rescue Exception - render(text: "Error loading your course's grading " \ - "configuration file. Please go here to reload the file, and try again") and - return false - end - true - end - -# Scores for grouping - def scores_for_grouping(grouping) - result = {} - grouping.keys.compact.sort.each do |group| - scoreresult = {} - problem_scores = problem_scores_for_group(grouping, group) - @assessment.problems.each do |problem| - scoreresult[problem.name] = problem_scores[problem.id] - end - result[group] = scoreresult - end - result - end - - # Problem scores for grouping - def problem_scores_for_group(grouping, group) - problem_scores = {} - - @assessment.problems.each do |problem| - problem_scores[problem.id] = [] - end - problem_scores[:total] = [] - - grouping[group].each do |submission| - next unless submission.course_user_datum.student? - # TODO(jezimmer): Find a more permanent fix (see #529) - #next unless submission.special_type == Submission::NORMAL - - submission.scores.each do |score| - problem_scores[score.problem_id] << score.score - end - problem_scores[:total] << submission.final_score(@cud) - end - problem_scores - end - -# Stats for grouping - def stats_for_grouping(grouping) - result = {} - problem_id_to_name = @assessment.problem_id_to_name - stats = Statistics.new - # There can be null keys here because some of the - # values we group by are nullable in the DB. We - # shouldn't show those. - grouping.keys.compact.sort.each do |group| - problem_scores = problem_scores_for_group(grouping,group) - # Need the problems to be in the right order. - problem_stats = {} - # seems like we always index with 1 - @assessment.problems.each do |problem| - problem_stats[problem.name] = stats.stats(problem_scores[problem.id]) - end - problem_stats[:Total] = stats.stats(problem_scores[:total]) - result[group] = {} - result[group][:data] = problem_stats - result[group][:total_students] = problem_scores[:total].length - end - # raise result.inspect - result - end - - # This is different from all of the others because it doesn't - # group by submission but by score (since multiple graders can - # grade problems for a single submission). - def stats_for_grader(submissions) - result = [] - problem_id_to_name = @assessment.problem_id_to_name - stats = Statistics.new - - grader_scores = {} - submissions.each do |submission| - next unless submission.special_type == Submission::NORMAL - - submission.scores.each do |score| - next if score.grader_id.nil? - if grader_scores.key? score.grader_id - grader_scores[score.grader_id] << score - else - grader_scores[score.grader_id] = [score] - end - end - end - - grader_ids = grader_scores.keys - def find_user(i) - if i == 0 - autograder = Hash["full_name", "Autograder", - "id", 0, - "full_name_with_email", "Autograder"] - def autograder.method_missing(m) - self[m.to_s] - end - return autograder - else - return @course.course_user_data.find(i) - end - end - grader_ids.filter! { |i| i != -1 } - graders = grader_ids.map(&method(:find_user)) - graders = graders.compact - graders.sort! { |g1, g2| g1.full_name <=> g2.full_name } - - graders.each do |grader| - scores = grader_scores[grader["id"]] - - problem_scores = {} - @assessment.problems.each do |problem| - problem_scores[problem.id] = [] - end - - scores.each do |score| - problem_scores[score.problem_id] << score.score - end - - problem_stats = [] - @assessment.problems.each do |problem| - problem_stats << [problem.name, stats.stats(problem_scores[problem.id])] - end - - result << [grader.full_name_with_email, problem_stats] - end - result - end - - # TODO - def load_gradesheet_data - @start = Time.now - id = @assessment.id - - # section filter - o = params[:section] ? { - conditions: { assessment_id: id, course_user_data: { section: @cud.section } } - } : { - conditions: { assessment_id: id } - } - - # currently loads *all* assessment AUDs, scores in spite of the section filter - # but that's okay, it only takes a couple 10ms - cache = AssociationCache.new(@course) do |_| - _.load_course_user_data - _.load_auds - _.load_latest_submissions o - _.load_latest_submission_scores(conditions: { submissions: { assessment_id: id } }) - _.load_assessments - end - - @assessment = cache.assessments[@assessment.id] - @submissions = cache.latest_submissions.values - end -end +require "csv" +require "utilities" + +module AssessmentGrading + # Export all scores for an assessment for all students as CSV + def bulkExport + # generate CSV + csv = render_to_string layout: false + + # send CSV file + timestamp = Time.now.strftime "%Y%m%d%H%M" + file_name = "#{@course.name}_#{@assessment.name}_#{timestamp}.csv" + send_data csv, filename: file_name + end + + # Allows the user to upload multiple scores or comments from a CSV file + def bulkGrade + return unless request.post? + + # part 1: submitting a CSV for processing and returning errors in CSV + if params[:upload] + # get data type + @data_type = params[:upload][:data_type].to_sym + unless @data_type == :scores || @data_type == :feedback + flash[:error] = "bulkGrade: invalid data_type received from client" + redirect_to(action: :bulkGrade) && return + end + + # get CSV + csv_file = params[:upload][:file] + if csv_file + @csv = csv_file.read + else + flash[:error] = "You need to choose a CSV file to upload." + redirect_to(action: :bulkGrade) && return + end + + # process CSV + success, entries = parse_csv @csv, @data_type + if success + @entries = entries + @valid_entries = valid_entries? entries + else + redirect_to(action: :bulkGrade) && return + end + end + end + + # part 2: confirming a CSV upload and saving data + def bulkGrade_complete + redirect_to(action: :bulkGrade) && return unless request.post? + + # retrieve entries CSV from hidden field in form + csv = params[:confirm][:bulkGrade_csv] + data_type = params[:confirm][:bulkGrade_data_type].to_sym + unless csv && data_type + flash[:error] = "Please try again." + redirect_to(action: :bulkGrade) && return + end + + success, @entries = parse_csv csv, data_type + if !success + flash[:error] = "bulkGrade_complete: invalid csv returned from client" + redirect_to(action: :bulkGrade) && return + elsif !valid_entries?(@entries) + flash[:error] = "bulkGrade_complete: invalid entries returned from client" + redirect_to(action: :bulkGrade) && return + end + + # save data + unless save_entries @entries, data_type + flash[:error] = "Failed to Save Entries" + redirect_to(action: :bulkGrade) && return + end + end + +private + + def valid_entries?(entries) + entries.reduce true do |acc, entry| + acc && valid_entry?(entry) + end + end + + def valid_entry?(entry) + entry.values.reduce true do |acc, v| + acc && (case v + when Hash + !v.include?(:error) && valid_entry?(v) + else + true + end) + end + end + + def save_entries(entries, data_type) + asmt = @assessment + + begin + User.transaction do + entries.each do |entry| + user = CourseUserDatum.joins(:user) + .find_by(users: { email: entry[:email] }, course: asmt.course) + + aud = AssessmentUserDatum.get asmt.id, user.id + if entry[:grade_type] + aud.grade_type = AssessmentUserDatum::GRADE_TYPE_MAP[entry[:grade_type]] + aud.save! + end + + unless sub = aud.latest_submission + sub = asmt.submissions.create!( + course_user_datum_id: user.id, + assessment_id: asmt.id, + submitted_by_id: @cud.id, + created_at: [Time.current, asmt.due_at].min + ) + end + + entry[:data].each do |problem_name, datum| + next unless datum + + problem = asmt.problems.find_by_name problem_name + + score = sub.scores.find_by_problem_id problem.id + unless score + score = sub.scores.new( + grader_id: @cud.id, + problem_id: problem.id + ) + end + + case data_type + when :scores + score.score = datum + when :feedback + score.feedback = datum.gsub("\\n", "\n").gsub("\\t", "\t") + end + + updateScore user.id, score + end + end # entries.each + end # User.transaction + + true + rescue ActiveRecord::ActiveRecordError => e + flash[:error] = "An error occurred: #{e}" + + false + end + end + + def parse_csv(csv, data_type) + # inputs for parse_csv_row + problems = @assessment.problems + emails = Set.new(CourseUserDatum.joins(:user).where(course: @assessment.course).map &:email) + + # process CSV + entries = [] + begin + CSV.parse(csv, skip_blanks: true) do |row| + entries << parse_csv_row(row, data_type, problems, emails) + end + rescue CSV::MalformedCSVError => e + flash[:error] = "Failed to parse CSV -- make sure the grades " \ + "are formatted correctly:
#{e}
" + flash[:html_safe] = true + return false, [] + end + + [true, entries] + end + + def parse_csv_row(row, kind, problems, emails) + row = row.dup + + email = row.shift.to_s + data = row.shift problems.count + grade_type = row.shift.to_s + + # to be returned + processed = {} + processed[:extra_cells] = row if row.length > 0 # currently unused + + # Checking that emails are valid + processed[:email] = if email.blank? + { error: nil } + elsif emails.include? email + email + else + { error: email } + end + + # data + data.map! do |datum| + if datum.blank? + nil + else + case kind + when :scores + Float(datum) rescue({ error: datum }) + when :feedback + datum + end + end + end + + # pad data with nil until there are problems.count elements + data.fill nil, data.length, problems.count - data.length + + processed[:data] = {} + problems.each_with_index { |problem, i| processed[:data][problem.name] = data[i] } + + # grade type + processed[:grade_type] = if grade_type.blank? + nil + elsif AssessmentUserDatum::GRADE_TYPE_MAP.key? grade_type.to_sym + grade_type.to_sym + else + { error: grade_type } + end + + processed + end + +public + + def quickSetScore + return unless request.post? + return unless params[:submission_id] + return unless params[:problem_id] + + # get submission and problem IDs + sub_id = params[:submission_id].to_i + prob_id = params[:problem_id].to_i + + # find existing score for this problem, if there's one + # otherwise, create it + score = Score.find_or_initialize_by_submission_id_and_problem_id(sub_id, prob_id) + + score.grader_id = @cud.id + score.score = params[:score].to_f + + updateScore(score.submission.course_user_datum_id, score) + + render plain: score.score + + # see http://stackoverflow.com/questions/6163125/duplicate-records-created-by-find-or-create-by + # and http://barelyenough.org/blog/2007/11/activerecord-race-conditions/ + # and http://stackoverflow.com/questions/5917355/find-or-create-race-conditions + rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordInvalid => error + @retries_left ||= 2 + retry unless (@retries_left -= 1) < 0 + raise error + end + + def quickSetScoreDetails + return unless request.post? + return unless params[:submission_id] + return unless params[:problem_id] + # get submission and problem IDs + sub_id = params[:submission_id].to_i + prob_id = params[:problem_id].to_i + + + # find existing score for this problem, if there's one + # otherwise, create it + score = Score.find_or_initialize_by_submission_id_and_problem_id(sub_id, prob_id) + + score.grader_id = @cud.id + score.feedback = params[:feedback] + score.released = params[:released] + + updateScore(score.submission.course_user_datum_id, score) + + render plain: score.id + + # see http://stackoverflow.com/questions/6163125/duplicate-records-created-by-find-or-create-by + # and http://barelyenough.org/blog/2007/11/activerecord-race-conditions/ + # and http://stackoverflow.com/questions/5917355/find-or-create-race-conditions +rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordInvalid => error + @retries_left ||= 2 + retry unless (@retries_left -= 1) < 0 + raise error +end + + def submission_popover + submission = Submission.find_by(id: params[:submission_id].to_i) + if submission + render partial: "popover", locals: { s: submission } + else + render plain: "Submission not found", status: :not_found + end + end + + def score_grader_info + score = Score.find(params[:score_id]) + grader = (if score then score.grader else nil end) + grader_info = "" + if grader + grader_info = grader.full_name_with_email + end + + feedback = score.feedback + response = { "grader" => grader_info, "feedback" => feedback, "score" => score.score } + render json: response + end + + def viewGradesheet + load_gradesheet_data + end + + def quickGetTotal + return unless params[:submission_id] + + # get submission and problem IDs + sub_id = params[:submission_id].to_i + + render plain: Submission.find(sub_id).final_score(@cud) + end + + def statistics + load_course_config + latest_submissions = @assessment.submissions.latest_for_statistics.includes(:scores, :course_user_datum) + #latest_submissions = @assessment.submissions.latest.includes(:scores, :course_user_datum) + + # Each value other than for :all is of the form + # [[, {:mean, :median, :max, :min, :stddev}]...] + # for each group. :all has just the hash. + @statistics = {} + @scores = {} + # Rather than special case this, we just index into the result. + by_assessment = latest_submissions.group_by { |s| s.assessment.name } + assessment_stats = stats_for_grouping(by_assessment) + all_grouping = assessment_stats[assessment_stats.keys[0]] + + if all_grouping.nil? + @statistics[:all] = [] + @scores[:all] = [] + else + @statistics[:all] = all_grouping[:data] + @scores[:all] = all_grouping[1] + end + + by_course_number = latest_submissions.group_by { |s| s.course_user_datum.course_number } + @statistics[:course_number] = stats_for_grouping(by_course_number) + @scores[:course_number] = scores_for_grouping(by_course_number) + + by_lecture = latest_submissions.group_by { |s| s.course_user_datum.lecture } + @statistics[:lecture] = stats_for_grouping(by_lecture) + @scores[:lecture] = scores_for_grouping(by_lecture) + + by_section = latest_submissions.group_by { |s| s.course_user_datum.section } + @statistics[:section] = stats_for_grouping(by_section) + @scores[:section] = scores_for_grouping(by_section) + + by_school = latest_submissions.group_by { |s| s.course_user_datum.school } + @statistics[:school] = stats_for_grouping(by_school) + @scores[:school] = scores_for_grouping(by_school) + + by_major = latest_submissions.group_by { |s| s.course_user_datum.major } + @statistics[:major] = stats_for_grouping(by_major) + @scores[:major] = scores_for_grouping(by_major) + + by_year = latest_submissions.group_by { |s| s.course_user_datum.year } + @statistics[:year] = stats_for_grouping(by_year) + @scores[:year] = scores_for_grouping(by_year) + @statistics[:grader] = stats_for_grader(latest_submissions) + end + +private + + def load_course_config + course = @course.name.gsub(/[^A-Za-z0-9]/, "") + begin + load(File.join(Rails.root, "courseConfig", + "#{course}.rb")) + eval("extend(Course#{course.camelize})") + rescue LoadError, SyntaxError, NameError, NoMethodError => e + @error = e + end + end + +# Scores for grouping + def scores_for_grouping(grouping) + result = {} + grouping.keys.compact.sort.each do |group| + scoreresult = {} + problem_scores = problem_scores_for_group(grouping, group) + @assessment.problems.each do |problem| + scoreresult[problem.name] = problem_scores[problem.id] + end + result[group] = scoreresult + end + result + end + + # Problem scores for grouping + def problem_scores_for_group(grouping, group) + problem_scores = {} + + @assessment.problems.each do |problem| + problem_scores[problem.id] = [] + end + problem_scores[:total] = [] + + grouping[group].each do |submission| + next unless submission.course_user_datum.student? + # TODO(jezimmer): Find a more permanent fix (see #529) + #next unless submission.special_type == Submission::NORMAL + + submission.scores.each do |score| + problem_scores[score.problem_id] << score.score + end + problem_scores[:total] << submission.final_score(@cud) + end + problem_scores + end + +# Stats for grouping + def stats_for_grouping(grouping) + result = {} + problem_id_to_name = @assessment.problem_id_to_name + stats = Statistics.new + # There can be null keys here because some of the + # values we group by are nullable in the DB. We + # shouldn't show those. + grouping.keys.compact.sort.each do |group| + problem_scores = problem_scores_for_group(grouping,group) + # Need the problems to be in the right order. + problem_stats = {} + # seems like we always index with 1 + @assessment.problems.each do |problem| + problem_stats[problem.name] = stats.stats(problem_scores[problem.id]) + end + problem_stats[:Total] = stats.stats(problem_scores[:total]) + result[group] = {} + result[group][:data] = problem_stats + result[group][:total_students] = problem_scores[:total].length + end + # raise result.inspect + result + end + + # This is different from all of the others because it doesn't + # group by submission but by score (since multiple graders can + # grade problems for a single submission). + def stats_for_grader(submissions) + result = [] + problem_id_to_name = @assessment.problem_id_to_name + stats = Statistics.new + + grader_scores = {} + submissions.each do |submission| + next unless submission.special_type == Submission::NORMAL + + submission.scores.each do |score| + next if score.grader_id.nil? + if grader_scores.key? score.grader_id + grader_scores[score.grader_id] << score + else + grader_scores[score.grader_id] = [score] + end + end + end + + grader_ids = grader_scores.keys + def find_user(i) + if i == 0 + autograder = Hash["full_name", "Autograder", + "id", 0, + "full_name_with_email", "Autograder"] + def autograder.method_missing(m) + self[m.to_s] + end + + autograder + else + @course.course_user_data.find(i) + end + end + grader_ids.filter! { |i| i != -1 } + graders = grader_ids.map(&method(:find_user)) + graders = graders.compact + graders.sort! { |g1, g2| g1.full_name <=> g2.full_name } + + graders.each do |grader| + scores = grader_scores[grader["id"]] + + problem_scores = {} + @assessment.problems.each do |problem| + problem_scores[problem.id] = [] + end + + scores.each do |score| + problem_scores[score.problem_id] << score.score + end + + problem_stats = [] + @assessment.problems.each do |problem| + problem_stats << [problem.name, stats.stats(problem_scores[problem.id])] + end + + result << [grader.full_name_with_email, problem_stats] + end + result + end + + # TODO + def load_gradesheet_data + @start = Time.now + id = @assessment.id + + # lecture/section filter + o = params[:section] ? { + conditions: { assessment_id: id, course_user_data: { lecture: @cud.lecture, section: @cud.section } } + } : { + conditions: { assessment_id: id } + } + + # currently loads *all* assessment AUDs, scores in spite of the section filter + # but that's okay, it only takes a couple 10ms + cache = AssociationCache.new(@course) do |_| + _.load_course_user_data + _.load_auds + _.load_latest_submissions o + _.load_latest_submission_scores(conditions: { submissions: { assessment_id: id } }) + _.load_assessments + end + + @assessment = cache.assessments[@assessment.id] + @submissions = cache.latest_submissions.values + @section_filter = params[:section] + end +end diff --git a/app/controllers/assessment/handin.rb b/app/controllers/assessment/handin.rb index 064f0999b..2874ee253 100755 --- a/app/controllers/assessment/handin.rb +++ b/app/controllers/assessment/handin.rb @@ -69,6 +69,7 @@ def handin }) COURSE_LOGGER.log("could not save handin: #{exception.class} (#{exception.message})") + flash[:error] = exception.message submissions = nil end @@ -79,10 +80,7 @@ def handin # make sure submission was correctly constructed and saved unless submissions - # Avoid overwriting the flash[:error] set by saveHandin - if !flash[:error].nil? && !flash[:error].empty? - flash[:error] = "There was an error handing in your submission." - end + flash[:error] ||= "There was an error handing in your submission." redirect_to(action: :show) && return end @@ -177,15 +175,13 @@ def local_submit assessment: @assessment, }) COURSE_LOGGER.log("Error Saving Submission:\n#{e}") + flash[:error] = exception.message submissions = nil end # make sure submission was correctly constructed and saved unless submissions - # Avoid overwriting the flash[:error] set by saveHandin - if !flash[:error].nil? && !flash[:error].empty? - flash[:error] = "There was an error handing in your submission." - end + flash[:error] ||= "There was an error handing in your submission." render(plain: flash[:error], status: :bad_request) && return end diff --git a/app/controllers/assessment/handout.rb b/app/controllers/assessment/handout.rb index 7021649c9..b0237f0fa 100644 --- a/app/controllers/assessment/handout.rb +++ b/app/controllers/assessment/handout.rb @@ -1,3 +1,5 @@ +require "archive" + ## # Defines handout method, so students can get handout # @@ -16,6 +18,12 @@ def handout if @assessment.overwrites_method?(:handout) hash = @assessment.config_module.handout + # Ensure that handout lies within the assessment folder + unless Archive.in_dir?(Pathname(hash["fullpath"]), @assessment.folder_path) + flash.now[:error] = "Invalid handout path: #{hash["fullpath"]} does not lie within the assessment folder." + return + end + send_file(hash["fullpath"], disposition: "inline", filename: hash["filename"]) @@ -25,6 +33,7 @@ def handout redirect_to(@assessment.handout) && return if @assessment.handout_is_url? if @assessment.handout_is_file? + # Note: handout_is_file? validates that the handout lies within the assessment folder filename = @assessment.handout_path send_file(filename, disposition: "inline", diff --git a/app/controllers/assessments_controller.rb b/app/controllers/assessments_controller.rb index d9fd4dca5..37786e070 100755 --- a/app/controllers/assessments_controller.rb +++ b/app/controllers/assessments_controller.rb @@ -37,7 +37,6 @@ class AssessmentsController < ApplicationController action_auth_level :submission_popover, :course_assistant action_auth_level :score_grader_info, :course_assistant action_auth_level :viewGradesheet, :course_assistant - action_auth_level :viewGradesheet2, :course_assistant action_auth_level :quickGetTotal, :course_assistant action_auth_level :statistics, :instructor @@ -68,20 +67,12 @@ def index .where(persistent: false) @announcements = announcements_tmp.where(course_id: @course.id) .or(announcements_tmp.where(system: true)).order(:start_date) - @attachments = if @cud.instructor? - @course.attachments - else - # Attachments that are released, and whose related assessment is also released - course_attachments = @course.attachments - .where(released: true) - .left_outer_joins(:assessment) - - # Either assessment_id is nil (i.e. course attachment) - # Or the assessment has started - course_attachments.where(assessment_id: nil) - .or(course_attachments.where("assessments.start_at < ?", - Time.current)) - end + # Only display course attachments on course landing page + @course_attachments = if @cud.instructor? + @course.attachments.where(assessment_id: nil) + else + @course.attachments.where(assessment_id: nil).released + end end # GET /assessments/new @@ -111,31 +102,20 @@ def install_assessment filename)) || (filename == "..") || (filename == ".") # assessment names must be only lowercase letters and digits - if filename =~ /[^a-z0-9]/ + if filename !~ Assessment::VALID_NAME_REGEX # add line break if adding to existing error message flash.now[:error] = flash.now[:error] ? "#{flash.now[:error]}
" : "" flash.now[:error] += "An error occurred while trying to display an existing assessment " \ - "from file directory #{filename}: assessment file names must only contain lowercase " \ - "letters and digits with no spaces" + "from file directory #{filename}: Invalid assessment name. "\ + "Find more information on valid assessment names "\ + 'here
' flash.now[:html_safe] = true next end # each assessment must have an associated yaml file, # and it must have a name field that matches its filename - if File.exist?(File.join(dir_path, filename, "#{filename}.yml")) - props = YAML.safe_load(File.open( - File.join(dir_path, filename, "#{filename}.yml"), "r", &:read - )) - unless props["general"] && (props["general"]["name"] == filename) - flash.now[:error] = flash.now[:error] ? "#{flash.now[:error]}
" : "" - flash.now[:error] += "An error occurred while trying to display an existing assessment " \ - "from file directory #{filename}: Name in yaml (#{props['general']['name']}) " \ - "doesn't match #{filename}" - flash.now[:html_safe] = true - next - end - else + unless File.exist?(File.join(dir_path, filename, "#{filename}.yml")) flash.now[:error] = flash.now[:error] ? "#{flash.now[:error]}
" : "" flash.now[:error] += "An error occurred while trying to display an existing assessment " \ "from file directory #{filename}: #{filename}.yml does not exist" @@ -170,7 +150,7 @@ def importAsmtFromTar flash[:error] += "
Invalid tarball. A valid assessment tar has a single root "\ "directory that's named after the assessment, containing an "\ - "assessment yaml file and an assessment ruby file." + "assessment yaml file" flash[:html_safe] = true redirect_to(action: "install_assessment") && return end @@ -290,20 +270,39 @@ def importAssessment def create @assessment = @course.assessments.new(new_assessment_params) - if @assessment.name.blank? - # Validate the name - ass_name = @assessment.display_name.downcase.gsub(/[^a-z0-9]/, "") + # Validate the name, very similar to valid Ruby identifiers, but also allowing hyphens + # We just want to prevent file traversal attacks here, and stop names that break routing + # first regex - try to sanitize input, allow special characters in display name but not name + # if the sanitized doesn't match the required identifier structure, then we reject + begin + # Attempt name generation, try to match to a substring that is valid within the display name + match = @assessment.display_name.match(Assessment::VALID_NAME_SANITIZER_REGEX) + unless match.nil? + sanitized_display_name = match.captures[0] + end - if ass_name.blank? + if sanitized_display_name !~ Assessment::VALID_NAME_REGEX + flash[:error] = + "Assessment name is blank or contains disallowed characters. Find more information on "\ + "valid assessment names "\ + 'here' + flash[:html_safe] = true + redirect_to(action: :install_assessment) + return + end + rescue StandardError flash[:error] = - "Assessment name is blank or contains characters that are not lowercase letters or digits" + "Error creating name from display name. Find more information on "\ + "valid assessment names "\ + 'here' + flash[:html_safe] = true redirect_to(action: :install_assessment) return end # Update name in object - @assessment.name = ass_name + @assessment.name = sanitized_display_name end # fill in other fields @@ -319,7 +318,6 @@ def create @assessment.start_at = Time.current + 1.day @assessment.due_at = Time.current + 1.day @assessment.end_at = Time.current + 1.day - @assessment.grading_deadline = Time.current + 1.day @assessment.quiz = false @assessment.quizData = "" @assessment.max_submissions = params.include?(:max_submissions) ? params[:max_submissions] : -1 @@ -353,7 +351,12 @@ def create flash[:success] = "Successfully installed #{@assessment.name}." # reload the course config file - @course.reload_course_config + begin + @course.reload_course_config + rescue StandardError, SyntaxError => e + @error = e + render("reload") && return + end redirect_to([@course, @assessment]) && return end @@ -492,6 +495,12 @@ def destroy if File.exist? @assessment.config_backup_file_path File.delete @assessment.config_backup_file_path end + if File.exist? @assessment.unique_config_file_path + File.delete @assessment.unique_config_file_path + end + if File.exist? @assessment.unique_config_backup_file_path + File.delete @assessment.unique_config_backup_file_path + end name = @assessment.display_name @assessment.destroy # awwww!!!! @@ -503,7 +512,21 @@ def destroy def show set_handin - extend_config_module(@assessment, @submission, @cud) + begin + extend_config_module(@assessment, @submission, @cud) + rescue AutogradeError => e + if @cud.has_auth_level? :instructor + flash[:error] = "Error loading the config file: " + flash[:error] += e.message + flash[:error] += "
Try reloading the course config file," \ + " or re-upload the course config file in order to recover your assessment." + flash[:html_safe] = true + redirect_to(edit_course_assessment_path(@course, @assessment)) && return + else + flash[:error] = "Error loading #{@assessment.display_name}. Please contact your instructor." + redirect_to(course_path(@course)) && return + end + end @aud = @assessment.aud_for @cud.id @@ -527,7 +550,7 @@ def show @attachments = if @cud.instructor? @assessment.attachments else - @assessment.attachments.where(released: true) + @assessment.attachments.released end @submissions = @assessment.submissions.where(course_user_datum_id: @effectiveCud.id) .order("version DESC") @@ -563,10 +586,9 @@ def show @repos = GithubIntegration.find_by(user_id: @cud.user.id)&.repositories - return unless @assessment.invalid? + return unless @assessment.invalid? && @cud.instructor? - # On the off-chance that the assessment has validation errors, let the user know - # as otherwise submissions would silently fail + # If the assessment has validation errors, let the instructor know flash.now[:error] = "This assessment is invalid due to the following error(s):
" flash.now[:error] += @assessment.errors.full_messages.join("
") flash.now[:html_safe] = true @@ -627,16 +649,17 @@ def history def viewFeedback # User requested to view feedback on a score @score = @submission.scores.find_by(problem_id: params[:feedback]) + autograded_scores = @submission.scores.includes(:problem).where(grader_id: 0) # Checks whether at least one problem has finished being auto-graded - @finishedAutograding = @submission.scores.where.not(feedback: nil).where(grader_id: 0) + finishedAutograding = @submission.scores.where.not(feedback: nil).where(grader_id: 0) @job_id = @submission["jobid"] @submission_id = params[:submission_id] # Autograding is not in-progress and no score is available if @score.nil? - if !@finishedAutograding.empty? + if !finishedAutograding.empty? redirect_to(action: "viewFeedback", - feedback: @finishedAutograding.first.problem_id, + feedback: finishedAutograding.first.problem_id, submission_id: params[:submission_id]) && return end @@ -650,12 +673,14 @@ def viewFeedback return if @score.nil? @jsonFeedback = parseFeedback(@score.feedback) - @scoreHash = parseScore(@score.feedback) + + raw_score_hash = scoreHashFromScores(autograded_scores) if @score.grader_id <= 0 + @scoreHash = parseScore(raw_score_hash) unless raw_score_hash.nil? + if Archive.archive? @submission.handin_file_path @files = Archive.get_files @submission.handin_file_path end - @problemReleased = @submission.scores.pluck(:released).all? && - !@assessment.before_grading_deadline? + # get_correct_filename is protected, so we wrap around controller-specific call @get_correct_filename = ->(annotation) { get_correct_filename(annotation, @files, @submission) @@ -690,28 +715,17 @@ def getPartialFeedback # TODO: Take into account any modifications by :parseAutoresult and :modifySubmissionScores # We should probably read the final scores directly # See: assessment_autograde_core.rb's saveAutograde - def parseScore(feedback) - return if feedback.nil? + def parseScore(score_hash) + total = 0 + return if score_hash.nil? - lines = feedback.rstrip.lines - feedback = lines[lines.length - 1] - - return unless valid_json_hash?(feedback) - - score_hash = JSON.parse(feedback) - score_hash = score_hash["scores"] if @jsonFeedback&.key?("_scores_order") == false @jsonFeedback["_scores_order"] = score_hash.keys end - @total = 0 score_hash.keys.each do |k| - @total += score_hash[k].to_f - rescue NoMethodError - flash.now[:error] ||= "" - flash.now[:error] += "The score for #{k} could not be parsed.
" - flash.now[:html_safe] = true + total += score_hash[k].to_f if score_hash[k] end - score_hash["_total"] = @total + score_hash["_total"] = total score_hash end @@ -756,7 +770,7 @@ def reload @assessment.load_config_file rescue StandardError, SyntaxError => e @error = e - # let the reload view render + # let the reload view render else flash[:success] = "Success: Assessment config file reloaded!" redirect_to(action: :show) && return @@ -773,13 +787,38 @@ def edit params[:active_tab] = "basic" end - # make sure the penalties are set up - @assessment.late_penalty ||= Penalty.new(kind: "points") - @assessment.version_penalty ||= Penalty.new(kind: "points") - @has_annotations = @assessment.submissions.any? { |s| !s.annotations.empty? } @is_positive_grading = @assessment.is_positive_grading + + # warn instructors if the assessment is configured to allow late submissions + # but the settings do not make sense + if @assessment.end_at > @assessment.due_at + warn_messages = [] + if @assessment.max_grace_days == 0 + warn_messages << "- Max grace days = 0: students can't use grace days" + end + if @assessment.effective_late_penalty.value == 0 + warn_messages << "- Late penalty = 0: late submissions made \ + without grace days are not penalized" + end + unless warn_messages.empty? + flash.now[:error] = "Late submissions are allowed, but
#{warn_messages.join('
')}" + flash.now[:html_safe] = true + end + end + + # Used for the penalties tab + @has_unlimited_submissions = @assessment.max_submissions == -1 + @has_unlimited_grace_days = @assessment.max_grace_days.nil? + @uses_default_version_threshold = @assessment.version_threshold.nil? + @uses_default_late_penalty = @assessment.late_penalty.nil? + @uses_default_version_penalty = @assessment.version_penalty.nil? + + # make sure the penalties are set up + # placed after the check above, so that effective_late_penalty displays the correct result + @assessment.late_penalty ||= Penalty.new(kind: "points") + @assessment.version_penalty ||= Penalty.new(kind: "points") end action_auth_level :update, :instructor @@ -794,7 +833,7 @@ def update unless uploaded_config_file.nil? config_source = uploaded_config_file.read - assessment_config_file_path = @assessment.source_config_file_path + assessment_config_file_path = @assessment.unique_source_config_file_path File.open(assessment_config_file_path, "w") do |f| f.write(config_source) end @@ -812,8 +851,9 @@ def update flash[:success] = "Assessment configuration updated!" redirect_to(tab_index) && return - rescue ActiveRecord::RecordInvalid => e - flash[:error] = e.message.sub!("Validation failed: ", "") + rescue ActiveRecord::RecordInvalid + flash[:error] = @assessment.errors.full_messages.join("
") + flash[:html_safe] = true redirect_to(tab_index) && return end @@ -828,7 +868,7 @@ def releaseAllGrades if num_released > 0 flash[:success] = format("%d %s released.", - num_released: num_released, + num_released:, plurality: (num_released > 1 ? "grades were" : "grade was")) else flash[:error] = "No grades were released. They might have all already been released." @@ -853,7 +893,7 @@ def releaseSectionGrades if num_released > 0 flash[:success] = format("%d %s released.", - num_released: num_released, + num_released:, plurality: (num_released > 1 ? "grades were" : "grade was")) else flash[:error] = "No grades were released. " \ @@ -861,7 +901,7 @@ def releaseSectionGrades "might be assigned to a lecture " \ "and/or section that doesn't exist. Please contact an instructor." end - redirect_to action: "viewGradesheet" + redirect_to url_for(action: 'viewGradesheet', section: '1') end action_auth_level :withdrawAllGrades, :instructor @@ -896,6 +936,7 @@ def writeup end if @assessment.writeup_is_file? + # Note: writeup_is_file? validates that the writeup lies within the assessment folder filename = @assessment.writeup_path send_file(filename, type: mime_type_from_ext(File.extname(filename)), @@ -983,6 +1024,28 @@ def edit_assessment_params @assessment.version_penalty&.destroy end + if params[:unlimited_submissions].to_boolean == true + ass[:max_submissions] = -1 + end + + if params[:unlimited_grace_days].to_boolean == true + ass[:max_grace_days] = "" + end + + if params[:use_default_late_penalty].to_boolean == true + ass.delete(:late_penalty_attributes) + @assessment.late_penalty&.destroy + end + + if params[:use_default_version_penalty].to_boolean == true + ass.delete(:version_penalty_attributes) + @assessment.version_penalty&.destroy + end + + if params[:use_default_version_threshold].to_boolean == true + ass[:version_threshold] = "" + end + ass.delete(:name) ass.delete(:config_file) ass.delete(:embedded_quiz_form) @@ -992,11 +1055,10 @@ def edit_assessment_params ## # a valid assessment tar has a single root directory that's named after the - # assessment, containing an assessment yaml file and an assessment ruby file + # assessment, containing an assessment yaml file # def valid_asmt_tar(tar_extract) asmt_name = nil - asmt_rb_exists = false asmt_yml_exists = false asmt_name_is_valid = true tar_extract.each do |entry| @@ -1029,8 +1091,6 @@ def valid_asmt_tar(tar_extract) # validate syntax of config RubyVM::InstructionSequence.compile(config_source) - - asmt_rb_exists = true end asmt_yml_exists = true if pathname == "#{asmt_name}/#{asmt_name}.yml" end @@ -1038,22 +1098,20 @@ def valid_asmt_tar(tar_extract) # it is possible that the assessment path does not match the # the expected assessment path when the Ruby config file # has a different name then the pathname - if !asmt_name.nil? && asmt_name =~ /[^a-z0-9]/ + if !asmt_name.nil? && asmt_name !~ Assessment::VALID_NAME_REGEX flash[:error] = "Errors found in tarball: Assessment name #{asmt_name} is invalid. - Assessment file names must only contain lowercase - letters and digits with no spaces." + Find more information on valid assessment names "\ + 'here
' + flash[:html_safe] = true asmt_name_is_valid = false end - if !(asmt_rb_exists && asmt_yml_exists && !asmt_name.nil?) + if !(asmt_yml_exists && !asmt_name.nil?) flash[:error] = "Errors found in tarball:" if !asmt_yml_exists && !asmt_name.nil? flash[:error] += "
Assessment yml file #{asmt_name}/#{asmt_name}.yml was not found" end - if !asmt_rb_exists && !asmt_name.nil? - flash[:error] += "
Assessment rb file #{asmt_name}/#{asmt_name}.rb was not found" - end end - [asmt_rb_exists && asmt_yml_exists && !asmt_name.nil? && asmt_name_is_valid, asmt_name] + [asmt_yml_exists && !asmt_name.nil? && asmt_name_is_valid, asmt_name] end def tab_index @@ -1088,4 +1146,10 @@ def destroy_no_redirect @assessment.destroy # awwww!!!! end + + def scoreHashFromScores(scores) + scores.map { |s| + [s.problem.name, s.score] + }.to_h + end end diff --git a/app/controllers/attachments_controller.rb b/app/controllers/attachments_controller.rb index 84b505e28..ffb5f3d0b 100755 --- a/app/controllers/attachments_controller.rb +++ b/app/controllers/attachments_controller.rb @@ -11,7 +11,11 @@ class AttachmentsController < ApplicationController action_auth_level :index, :instructor def index - @attachments = @is_assessment ? @assessment.attachments : @course.attachments + @attachments = if @is_assessment + @assessment.attachments.ordered + else + @course.attachments.where(assessment_id: nil).ordered + end end action_auth_level :new, :instructor @@ -29,7 +33,7 @@ def create if @attachment.update(attachment_params) flash[:success] = "Attachment created" - redirect_to_attachment_list + redirect_to_index else error_msg = "Attachment create failed:" if !@attachment.valid? @@ -53,23 +57,33 @@ def create action_auth_level :show, :student def show - filename = Rails.root.join("attachments", @attachment.filename) - unless File.exist?(filename) - COURSE_LOGGER.log("Cannot find the file '#{@attachment.filename}' for"\ - " attachment #{@attachment.name}") - - flash[:error] = "Error loading #{@attachment.name} from #{@attachment.filename}" - redirect_to([@course, :attachments]) && return - end if @cud.instructor? || @attachment.released? - # Set to application/octet-stream to force download - send_file(filename, disposition: "inline", - type: "application/octet-stream", - filename: @attachment.filename) && return + begin + attached_file = @attachment.attachment_file + if attached_file.attached? + send_data attached_file.download, filename: @attachment.filename, + type: @attachment.mime_type + return + end + + old_attachment_path = Rails.root.join("attachments", @attachment.filename) + if File.exist?(old_attachment_path) + send_file old_attachment_path, filename: @attachment.filename, type: @attachment.mime_type + return + else + COURSE_LOGGER.log("No file attached to attachment '#{@attachment.name}'") + flash[:error] = "No file attached to attachment '#{@attachment.name}'" + redirect_to_index && return + end + rescue StandardError + COURSE_LOGGER.log("Error viewing attachment '#{@attachment.name}'") + flash[:error] = "Error viewing attachment '#{@attachment.name}'" + redirect_to_index && return + end end flash[:error] = "You are unauthorized to view this attachment" - redirect_to([@course, @assessment]) + redirect_to_index end action_auth_level :edit, :instructor @@ -79,7 +93,7 @@ def edit; end def update if @attachment.update(attachment_params) flash[:success] = "Attachment updated" - redirect_to_attachment_list + redirect_to_index else error_msg = "Attachment update failed:" if !@attachment.valid? @@ -105,7 +119,7 @@ def update def destroy @attachment.destroy flash[:success] = "Attachment deleted" - redirect_to_attachment_list + redirect_to_index end private @@ -118,17 +132,17 @@ def set_attachment @attachment = if @is_assessment @course.attachments.find_by(assessment_id: @assessment.id, id: params[:id]) else - @course.attachments.find(params[:id]) + @course.attachments.find_by(id: params[:id]) end return unless @attachment.nil? COURSE_LOGGER.log("Cannot find attachment with id: #{params[:id]}") - flash[:error] = "Could not find Attachment \# #{params[:id]}" - redirect_to_attachment_list + flash[:error] = "Could not find Attachment \##{params[:id]}" + redirect_to_index end - def redirect_to_attachment_list + def redirect_to_index if @is_assessment redirect_to course_assessment_path(@course, @assessment) else @@ -138,14 +152,14 @@ def redirect_to_attachment_list def add_attachments_breadcrumb @breadcrumbs << if @is_assessment - (view_context.link_to "Assessment Attachments", - [@course, @assessment, :attachments]) + view_context.link_to "Assessment Attachments", + course_assessment_attachments_path(@course, @assessment) else - (view_context.link_to "Course Attachments", [@course, :attachments]) + view_context.link_to "Course Attachments", course_attachments_path(@course) end end def attachment_params - params.require(:attachment).permit(:name, :file, :released, :mime_type) + params.require(:attachment).permit(:name, :file, :category_name, :release_at, :mime_type) end end diff --git a/app/controllers/course_user_data_controller.rb b/app/controllers/course_user_data_controller.rb index d557f2aab..be70adc39 100755 --- a/app/controllers/course_user_data_controller.rb +++ b/app/controllers/course_user_data_controller.rb @@ -20,7 +20,7 @@ def create @newCUD = @course.course_user_data.new(cud_parameters) email = cud_parameters[:user_attributes][:email] - user = User.where(email: email).first + user = User.where(email:).first # check user existence if user.nil? # user is new @@ -149,15 +149,19 @@ def update params[:course_user_datum][:tweak_attributes][:_destroy] = true end + if params[:course_user_datum][:dropped] == "1" && !@editCUD.dropped? + flash[:notice] = "You have dropped #{@editCUD.email} from the course." + end # When we're finished editing, go back to the user table if @editCUD.update(edit_cud_params) flash[:success] = "Success: Updated user #{@editCUD.email}" - redirect_to([@course, @editCUD]) && return + redirect_to(course_course_user_datum_path(@course, @editCUD)) && return else COURSE_LOGGER.log(@editCUD.errors.full_messages.join(", ")) # error details are shown separately in the view flash[:error] = "Update failed.
" flash[:error] += @editCUD.errors.full_messages.join("
") + flash[:notice] = "" flash[:html_safe] = true redirect_to(action: :edit) && return end diff --git a/app/controllers/courses_controller.rb b/app/controllers/courses_controller.rb index 4c06a2dd6..7c5f92256 100755 --- a/app/controllers/courses_controller.rb +++ b/app/controllers/courses_controller.rb @@ -288,7 +288,7 @@ def add_users_from_emails { first_name: Regexp.last_match(1), email: Regexp.last_match(2) } # when it's first name last name else - { email: email } + { email: } end end @@ -446,7 +446,7 @@ def download_roster # to_csv avoids issues with commas output += [@course.semester, cud.user.email, user.last_name, user.first_name, cud.school, cud.major, cud.year, cud.grade_policy, - @course.name, cud.lecture, cud.section].to_csv + cud.course_number, cud.lecture, cud.section].to_csv end send_data output, filename: "roster.csv", type: "text/csv", disposition: "inline" end @@ -461,7 +461,7 @@ def email # don't email kids who dropped! @cuds = if section - @course.course_user_data.where(dropped: false, section: section) + @course.course_user_data.where(dropped: false, section:) else @course.course_user_data.where(dropped: false) end @@ -480,7 +480,14 @@ def email end action_auth_level :moss, :instructor - def moss; end + def moss + @courses = if @cud.user.administrator? + Course.all + else + Course.joins(:course_user_data) + .where(course_user_data: { user_id: @cud.user.id, instructor: true }) + end + end LANGUAGE_WHITELIST = %w[c cc java ml pascal ada lisp scheme haskell fortran ascii vhdl perl matlab python mips prolog spice vb csharp modula2 a8086 javascript plsql @@ -571,7 +578,9 @@ def run_moss system("chmod -R a+r #{tmp_dir}") ActiveRecord::Base.clear_active_connections! # Remove non text files when making a moss run - `~/Autolab/script/cleanMoss #{tmp_dir}` + Dir.chdir(Rails.root.join("script")) do + system("./cleanMoss #{tmp_dir}") + end # Now run the Moss command @mossCmdString = @mossCmd.join(" ") @mossOutput = `#{@mossCmdString} 2>&1` @@ -638,7 +647,7 @@ def write_cuds(cuds) major = new_cud[:major] year = new_cud[:year] - if (user = User.where(email: email).first).nil? + if (user = User.where(email:).first).nil? begin # Create a new user user = User.roster_create(email, first_name, last_name, school, @@ -668,7 +677,7 @@ def write_cuds(cuds) end end - existing = @course.course_user_data.where(user: user).first + existing = @course.course_user_data.where(user:).first # Make sure this user doesn't have a cud in the course if existing duplicates.add(new_cud[:email]) diff --git a/app/controllers/extensions_controller.rb b/app/controllers/extensions_controller.rb index eb3cc8e82..1f8f6bd50 100755 --- a/app/controllers/extensions_controller.rb +++ b/app/controllers/extensions_controller.rb @@ -44,8 +44,8 @@ def create existing_ext.save! else new_ext = @assessment.extensions.create( - days: days, - infinite: infinite, + days:, + infinite:, course_user_datum_id: cud_id, assessment_id: params[:extension][:assessment_id] ) diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 4f5a98448..af8c08bd2 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -36,7 +36,7 @@ def index def show @aud = @assessment.aud_for @cud.id unless @cud.instructor - (redirect_to(action: :new) && return) if @aud.group_id.nil? + redirect_to(action: :new) && return if @aud.group_id.nil? if @aud.group_id != params[:id].to_i redirect_to([@course, @assessment, @aud.group]) && return diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb index a53b58a3d..a356fbb75 100644 --- a/app/controllers/metrics_controller.rb +++ b/app/controllers/metrics_controller.rb @@ -89,7 +89,7 @@ def get_watchlist_instances entry["id"] end - render json: { risk_conditions: risk_hash, users: user_hash, instances: instances }, + render json: { risk_conditions: risk_hash, users: user_hash, instances: }, status: :ok rescue StandardError => e render json: { error: e.message }, status: :not_found @@ -240,7 +240,7 @@ def get_watchlist_configuration return end - render json: { category_blocklist: category_blocklist, allow_ca: allow_ca }, status: :ok + render json: { category_blocklist:, allow_ca: }, status: :ok end action_auth_level :update_watchlist_configuration, :instructor diff --git a/app/controllers/scoreboards_controller.rb b/app/controllers/scoreboards_controller.rb index ed4d53d1d..32726fcc7 100755 --- a/app/controllers/scoreboards_controller.rb +++ b/app/controllers/scoreboards_controller.rb @@ -85,11 +85,13 @@ def show # But, if this was an instructor, we want them to know about # this. if @cud.instructor? + # not using flash because could be too large of a message to pass @errorMessage = "An error occurred while calling " \ - "createScoreboardEntry(#{grade[:problems].inspect},"\ + "createScoreboardEntry(#{grade[:problems].inspect},\n"\ "#{grade[:autoresult]})" @error = e - render([@course, @assessment]) && return + Rails.logger.error("Scoreboard error in #{@course.name}/#{@assessment.name}: #{@error}") + render("scoreboards/edit") && return end end @@ -108,14 +110,14 @@ def show else scoreboardOrderSubmissions(a, b) end - rescue StandardError => e if @cud.instructor? + # not using flash because could be too large of a message to pass @errorMessage = "An error occurred while calling "\ - "scoreboardOrderSubmissions(#{a.inspect},"\ + "scoreboardOrderSubmissions(#{a.inspect},\n"\ "#{b.inspect})" @error = e - render([@course, @assessment]) && return + render("scoreboards/edit") && return end 0 # Just say they're equal! end @@ -249,7 +251,13 @@ def createScoreboardEntry(scores, autoresult) # from the scoreboard array object in the JSON autoresult. begin parsed = ActiveSupport::JSON.decode(autoresult) - raise if !parsed || !parsed["scoreboard"] + if !parsed["scoreboard"].is_a?(Array) && @cud.instructor? + flash[:error] = "Error parsing scoreboard for autograded assessment: scoreboard result is"\ + " not an array. Please ensure that the autograder returns scoreboard results as an array." + end + Rails.logger.error("Scoreboard error in #{@course.name}/#{@assessment.name}: " \ + "Scoreboard result is not an array") + raise if !parsed || !parsed["scoreboard"] || !parsed["scoreboard"].is_a?(Array) rescue StandardError # If there is no autoresult for this student (typically # because their code did not compile or it segfaulted and @@ -265,11 +273,17 @@ def createScoreboardEntry(scores, autoresult) end return entry rescue StandardError + if @cud.instructor? + flash[:error] = "Error parsing scoreboard for autograded assessment: Please ensure the"\ + " scoreboard results from the autograder are formatted to be an array, and the colspec"\ + " matches the expected format." + Rails.logger.error("Scoreboard error in #{@course.name}/#{@assessment.name}: " \ + "Scoreboard could not be parsed") + end # Give up and bail return ["-"] end end - # Found a valid scoreboard array, so simply return it. If we # wanted to be really careful, we would verify that the size # was the same size as the column specification. diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index 3eea587c6..dd59a4601 100755 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -93,7 +93,7 @@ def create @submission.submitted_by_id = @cud.id next unless @submission.save! # Now we have a version number! - if params[:submission]["file"]&.present? + if params[:submission]["file"].present? @submission.save_file(params[:submission]) end end @@ -181,23 +181,24 @@ def missing # should be okay, but untested action_auth_level :downloadAll, :course_assistant def downloadAll - flash[:error] = "Cannot index submissions for nil assessment" if @assessment.nil? + failure_redirect_path = if @cud.course_assistant + course_assessment_path(@course, @assessment) + else + course_assessment_submissions_path(@course, @assessment) + end unless @assessment.valid? + flash[:error] = "The assessment has errors which must be rectified." @assessment.errors.full_messages.each do |msg| flash[:error] += "
#{msg}" end flash[:html_safe] = true + redirect_to failure_redirect_path and return end if @assessment.disable_handins flash[:error] = "There are no submissions to download." - if @cud.course_assistant - redirect_to course_assessment_path(@course, @assessment) - else - redirect_to course_assessment_submissions_path(@course, @assessment) - end - return + redirect_to failure_redirect_path and return end submissions = if params[:final] @@ -220,12 +221,7 @@ def downloadAll if result.nil? flash[:error] = "There are no submissions to download." - if @cud.course_assistant - redirect_to course_assessment_path(@course, @assessment) - else - redirect_to course_assessment_submissions_path(@course, @assessment) - end - return + redirect_to failure_redirect_path and return end send_data(result.read, # to read from stringIO object returned by create_zip @@ -256,7 +252,7 @@ def download # Only show annotations if grades have been released or the user is an instructor @annotations = [] - if !@assessment.before_grading_deadline? || @cud.instructor || @cud.course_assistant + if @submission.grades_released?(@cud) @annotations = @submission.annotations.to_a end @@ -467,9 +463,6 @@ def view end end - @problemReleased = @submission.scores.pluck(:released).all? && - !@assessment.before_grading_deadline? - @annotations = @submission.annotations.to_a unless @submission.group_key.empty? group_submissions = @submission.group_associated_submissions @@ -480,7 +473,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 !@assessment.before_grading_deadline? || @cud.instructor || @cud.course_assistant + unless @submission.grades_released?(@cud) @annotations = [] end @@ -560,8 +553,8 @@ def view annotations_by_file = annotations_by_file.sort_by{ |a| [a[6], a[2]] }.group_by { |a| a[6] } @problemAnnotations[problem] = { - global_annotations: global_annotations, - annotations_by_file: annotations_by_file + global_annotations:, + annotations_by_file: } end @@ -615,8 +608,8 @@ def view matchedVersions << { version: submission.version, - header_position: header_position, - submission: submission + header_position:, + submission: } end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 48004ba13..e187c5409 100755 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -49,7 +49,7 @@ def show next unless cud.instructor? user_cud = - cud.course.course_user_data.where(user: user).first + cud.course.course_user_data.where(user:).first user_cuds << user_cud unless user_cud.nil? end @@ -281,7 +281,7 @@ def github_oauth authorize_url_params = { redirect_uri: "#{hostname}/users/github_oauth_callback", scope: "repo", - state: state + state: } redirect_to @gh_client.auth_code.authorize_url(authorize_url_params) end @@ -315,7 +315,7 @@ def github_oauth_callback end access_token = token.to_hash[:access_token] - github_integration.update!(access_token: access_token, oauth_state: nil) + github_integration.update!(access_token:, oauth_state: nil) flash[:success] = "Successfully connected with Github." redirect_to(root_path) && return end @@ -333,6 +333,40 @@ def github_revoke (redirect_to user_path(id: @user.id)) && return end + action_auth_level :change_password_for_user, :administrator + def change_password_for_user + user = User.find(params[:id]) + raw, enc = Devise.token_generator.generate(User, :reset_password_token) + user.reset_password_token = enc + user.reset_password_sent_at = Time.current + user.save(validate: false) + Devise.sign_in_after_reset_password = false + user_reset_link = edit_password_url(user, reset_password_token: raw) + admin_reset_link = update_password_for_user_user_path(user:) + flash[:success] = + "Click " \ + "#{view_context.link_to 'here', admin_reset_link, method: 'get'} " \ + "to reset #{user.display_name}'s password " \ + "
Or copy this link for the user to reset their own password: "\ + "#{user_reset_link}" + flash[:html_safe] = true + redirect_to(user_path) + end + + def update_password_for_user + @user = User.find(params[:id]) + return if params[:user].nil? || params[:user].is_a?(String) || @user.nil? + + if params[:user][:password] != params[:user][:password_confirmation] + flash[:error] = "Passwords do not match" + elsif @user.update(password: params[:user][:password]) + flash[:success] = "Password changed successfully" + redirect_to(root_path) + else + flash[:error] = "Password #{@user.errors[:password][0]}" + end + end + private def new_user_params 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 55a166073..ac593a77b 100755 --- a/app/form_builders/form_builder_with_date_time_input.rb +++ b/app/form_builders/form_builder_with_date_time_input.rb @@ -20,7 +20,7 @@ class FormBuilderWithDateTimeInput < ActionView::Helpers::FormBuilder field = super name, *(args + [options]) - wrap_field name, field, options[:help_text], options[:display_name] + wrap_field name, field, options end end @@ -28,14 +28,14 @@ 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: "10") + + (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") + class: "carrot", disabled: options[:disabled]) end) end - wrap_field name, fields, options[:help_text] + wrap_field name, fields, options end def submit(text, *args) @@ -132,13 +132,13 @@ def date_helper(name, options, strftime, date_format, alt_format) "data-date-greater-than": options[:greater_than] ) - wrap_field name, field, options[:help_text] + wrap_field name, field, options end - def wrap_field(name, field, help_text = nil, display_name = nil) - @template.content_tag :div, class: "input-field" do - label(name, display_name, class: "control-label") + - field + help_text(name, help_text) + 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]) end end diff --git a/app/helpers/assessment_autograde_core.rb b/app/helpers/assessment_autograde_core.rb index dc7351959..f43cce766 100644 --- a/app/helpers/assessment_autograde_core.rb +++ b/app/helpers/assessment_autograde_core.rb @@ -354,10 +354,8 @@ def autogradeInputFiles(ass_dir, assessment, submission) # submission is confirmed via dave key to have been created by Autolab # def autogradeDone(submissions, feedback) - ass_dir = @assessment.folder_path - submissions.each do |submission| - feedback_file = submission.autograde_feedback_path + feedback_file = submission.create_user_directory_and_return_autograde_feedback_path COURSE_LOGGER.log("Looking for feedback file:" + feedback_file) feedback.force_encoding("UTF-8") @@ -450,7 +448,7 @@ def saveAutograde(submissions, feedback) scores.keys.each do |key| problem = @assessment.problems.find_by(name: key) - raise AutogradeError.new("Problem \"" + key + "\" not found.") unless problem + raise AutogradeError, "Problem \"" + key + "\" not found." unless problem score = submission.scores.find_or_initialize_by(problem_id: problem.id) score.score = scores[key] score.feedback = feedback @@ -502,7 +500,19 @@ def parseAutoresult(autoresult, _isOfficial) end def extend_config_module(assessment, submission, cud) - require assessment.config_file_path + # autograde core calls might be called before migration to unique module name occurs, so need to add check + begin + if @assessment.use_unique_module_name + require assessment.unique_config_file_path + else + require assessment.config_file_path + end + rescue TypeError => e + raise AutogradeError, "could not find the assessment config file: #{e}" + rescue LoadError => e + raise AutogradeError, "could not load the assessment config file: #{e}" + end + # casted to local variable so that # they can be passed into `module_eval` diff --git a/app/helpers/assessment_handin_core.rb b/app/helpers/assessment_handin_core.rb index b6ccdd3f8..778e19d4b 100644 --- a/app/helpers/assessment_handin_core.rb +++ b/app/helpers/assessment_handin_core.rb @@ -18,6 +18,7 @@ def validateHandin(size, content_type, filename) if size > @assessment.max_size * (2**20) return :file_too_large end + # Check if mimetype is correct (if overwritten by assessment config) begin if @assessment.overwrites_method?(:checkMimeType) and @@ -42,6 +43,7 @@ def validateHandinForGroups submitter_aud = @assessment.aud_for(@cud.id) return :valid unless submitter_aud + group = submitter_aud.group return :valid unless group @@ -54,6 +56,7 @@ def validateHandinForGroups submission_count = aud.course_user_datum.submissions.where(assessment: @assessment).size next unless submission_count >= @assessment.max_submissions + return :group_submission_limit_exceeded end @@ -72,7 +75,7 @@ def validateHandinForGroups # Returns a list of the submissions created by this handin (aka a "logical submission"). def saveHandin(sub, app_id = nil) unless @assessment.has_groups? - submission = @assessment.submissions.create(course_user_datum_id: @cud.id, + submission = @assessment.submissions.create!(course_user_datum_id: @cud.id, submitter_ip: request.remote_ip, submitted_by_app_id: app_id) submission.save_file(sub) @@ -82,7 +85,7 @@ def saveHandin(sub, app_id = nil) aud = @assessment.aud_for @cud.id group = aud.group if group.nil? - submission = @assessment.submissions.create(course_user_datum_id: @cud.id, + submission = @assessment.submissions.create!(course_user_datum_id: @cud.id, submitter_ip: request.remote_ip, submitted_by_app_id: app_id) submission.save_file(sub) @@ -97,7 +100,7 @@ def saveHandin(sub, app_id = nil) ActiveRecord::Base.transaction do group.course_user_data.each do |cud| - submission = @assessment.submissions.create(course_user_datum_id: cud.id, + submission = @assessment.submissions.create!(course_user_datum_id: cud.id, submitter_ip: request.remote_ip, submitted_by_app_id: app_id, group_key: group_key) diff --git a/app/helpers/gradebook_helper.rb b/app/helpers/gradebook_helper.rb index aaf3f85fd..e8dffe63a 100755 --- a/app/helpers/gradebook_helper.rb +++ b/app/helpers/gradebook_helper.rb @@ -35,15 +35,19 @@ def gradebook_columns(matrix, course) columns << { id: asmt.name, name: asmt.display_name, field: asmt.name, sortable: true, cssClass: "computed assessment_final_score", - headerCssClass: "assessment_final_score", - before_grading_deadline: matrix.before_grading_deadline?(asmt.id) } + headerCssClass: "assessment_final_score", width: 150 } + + columns << { id: "#{asmt.name}_version", name: "Version", + field: "#{asmt.name}_version", + sortable: true, cssClass: "computed assessment_version", + headerCssClass: "assessment_version", width: 100 } end # category average column - columns << { id: cat, name: cat + " Average", + columns << { id: cat, name: "#{cat} Average", field: "#{cat}_category_average", sortable: true, cssClass: "computed category_average", - headerCssClass: "category_average", width: 100 } + headerCssClass: "category_average", width: 180 } end # course average column @@ -90,6 +94,9 @@ def gradebook_rows(matrix, course, section = nil, lecture = nil) next unless matrix.has_assessment? a.id cell = matrix.cell(a.id, cud.id) + aud = a.assessment_user_data.find_by(course_user_datum_id: cud.id) + row["#{a.name}_version"] = aud&.latest_submission&.version + row["#{a.name}_history_url"] = history_url(cud, a) row[a.name] = round cell["final_score"] row["#{a.name}_submission_status"] = cell["submission_status"] row["#{a.name}_grade_type"] = cell["grade_type"] @@ -127,9 +134,12 @@ def csv_header(matrix, course) header = %w(Email first_name last_name Lecture Section School Major Year grace_days_used penalty_late_days) course.assessment_categories.each do |cat| next unless matrix.has_category? cat + course.assessments_with_category(cat).each do |asmt| next unless matrix.has_assessment? asmt.id + header << asmt.name + header << "version" end header << "#{cat} Average" end @@ -170,6 +180,7 @@ def gradebook_csv(matrix, course) cell = matrix.cell(asmt.id, cud.id) row << formatted_status(cell["status"]) + row << cell["version"] grace_days += cell["grace_days"] late_days += cell["late_days"] end diff --git a/app/models/annotation.rb b/app/models/annotation.rb index efee8c775..2df2ab676 100755 --- a/app/models/annotation.rb +++ b/app/models/annotation.rb @@ -48,14 +48,14 @@ def update_non_autograded_score # Obtain sum of all annotations for this score if submission.group_key.empty? annotation_delta = Annotation - .where(submission_id: submission_id, - problem_id: problem_id) + .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: problem_id) + annotation_delta += submission.annotations.where(problem_id:) .map(&:value).sum { |v| v.nil? ? 0 : v } end end diff --git a/app/models/assessment.rb b/app/models/assessment.rb index a1d260e9b..7cb23f454 100755 --- a/app/models/assessment.rb +++ b/app/models/assessment.rb @@ -1,7 +1,7 @@ +require "archive" require "association_cache" require "fileutils" require "utilities" - class Assessment < ApplicationRecord # Mass-assignment # attr_protected :name @@ -26,6 +26,9 @@ class Assessment < ApplicationRecord validate :verify_dates_order validate :handin_directory_and_filename_or_disable_handins, if: :active? validate :handin_directory_exists_or_disable_handins, if: :active? + validate :valid_handout + validate :valid_writeup + validate :valid_handin_directory validates :max_size, :max_submissions, numericality: true validates :version_threshold, numericality: { only_integer: true, greater_than_or_equal_to: -1, allow_nil: true } @@ -34,7 +37,7 @@ class Assessment < ApplicationRecord validates :group_size, numericality: { only_integer: true, greater_than_or_equal_to: 1, allow_nil: true } validates :name, :display_name, :due_at, :end_at, :start_at, - :grading_deadline, :category_name, :max_size, :max_submissions, presence: true + :category_name, :max_size, :max_submissions, presence: true # Callbacks trim_field :name, :display_name, :handin_filename, :handin_directory, :handout, :writeup @@ -46,7 +49,8 @@ class Assessment < ApplicationRecord # Constants ORDERING = "due_at ASC, name ASC".freeze RELEASED = "start_at < ?".freeze - + VALID_NAME_REGEX = /^[A-Za-z][A-Za-z0-9_-]*$/ + VALID_NAME_SANITIZER_REGEX = /^[^A-Za-z]*([A-Za-z0-9_-]+)/ # Scopes scope :ordered, -> { order(ORDERING) } scope :released, ->(as_of = Time.current) { where(RELEASED, as_of) } @@ -86,10 +90,6 @@ def assessment_before self_index > 0 ? sorted_asmts[self_index - 1] : nil end - def before_grading_deadline? - Time.current <= grading_deadline - end - def folder_path Rails.root.join("courses", course.name, name) end @@ -125,14 +125,30 @@ def config_file_path Rails.root.join("assessmentConfig", "#{course.name}-#{sanitized_name}.rb") end + def unique_config_file_path + Rails.root.join("assessmentConfig", "#{course.name}-#{name}-#{id}.rb") + end + def config_backup_file_path config_file_path.sub_ext(".rb.bak") end + def unique_config_backup_file_path + unique_config_file_path.sub_ext(".rb.bak") + end + def config_module_name (sanitized_name + course.sanitized_name).camelize end + def unique_config_module_name + "#{sanitized_name}#{id}".camelize + end + + def use_unique_module_name + File.exist? unique_config_file_path + end + def config @config ||= config! end @@ -185,7 +201,7 @@ def construct_folder # returns true if the file is actually created # def construct_default_config_file - assessment_config_file_path = source_config_file_path + assessment_config_file_path = unique_source_config_file_path return false if File.file?(assessment_config_file_path) # Open and read the default assessment config file @@ -193,14 +209,10 @@ def construct_default_config_file config_source = File.open(default_config_file_path, "r", &:read) # Update with this assessment information - config_source.gsub!("##NAME_CAMEL##", name.camelize) + config_source.gsub!("##NAME_CAMEL##", unique_config_module_name) config_source.gsub!("##NAME_LOWER##", name) - # Write the new config out to the right file. File.open(assessment_config_file_path, "w") { |f| f.write(config_source) } - # Load the assessment config file while we're at it - File.open(config_file_path, "w") { |f| f.write config_source } - true end @@ -211,39 +223,56 @@ def construct_default_config_file # WILL NOT WORK ON NEW, UNSAVED ASSESSMENTS!!! # def load_config_file + # migrate old source config file path, in dir check to ensure that we are not trying to migrate + # a different module in a different folder that has a asmt name that maps to the old system + if (File.exist? source_config_file_path) && + (source_config_file_path != unique_source_config_file_path) && + Archive.in_dir?(source_config_file_path, folder_path) + # read from source + config_source = File.open(source_config_file_path, "r", &:read) + RubyVM::InstructionSequence.compile(config_source) + # rename module name if it doesn't match new unique naming scheme + if config_source !~ /\b#{unique_config_module_name}\b/ + match = config_source.match(/module\s+(\w+)/) + config_source = config_source.sub(match[0], "module #{unique_config_module_name}") + end + File.open(unique_source_config_file_path, "w"){ |f| f.write(config_source) } + File.rename(source_config_file_path, source_config_file_backup_path) + end + # read from source - config_source = File.open(source_config_file_path, "r", &:read) + config_source = File.open(unique_source_config_file_path, "r", &:read) # validate syntax of config RubyVM::InstructionSequence.compile(config_source) # ensure source_config_module_name is an actual module in the assessment config rb file # otherwise loading the file on subsequent calls to config_module will result in an exception - if config_source !~ /\b#{source_config_module_name}\b/ - raise "Module name in #{name}.rb - doesn't match expected #{source_config_module_name}" - end # uniquely rename module (so that it's unique among all assessment modules loaded in Autolab) - config = config_source.gsub("module #{source_config_module_name}", - "module #{config_module_name}") - # backup old config - if File.exist?(config_file_path) - File.rename(config_file_path, config_backup_file_path) + if config_source !~ /\b#{unique_config_module_name}\b/ + match = config_source.match(/module\s+(\w+)/) + config_source = config_source.sub(match[0], "module #{unique_config_module_name}") + end + + # backup old *unique* configs + # we keep the previous config_file_path, if it exists, to allow for the unique file path changes + # to be reverted without breaking all previous existing assessments + if File.exist?(unique_config_file_path) + File.rename(unique_config_file_path, unique_config_backup_file_path) end # write to config_file_path - File.open(config_file_path, "w") { |f| f.write config } + File.open(unique_config_file_path, "w") { |f| f.write config_source } # config file might have an updated custom raw score function: clear raw score cache invalidate_raw_scores - - logger.info "Loaded #{config_file_path}" + logger.info "Loaded #{unique_config_file_path}" end def config_module # (re)construct config file from source, unless it already exists - load_config_file unless File.exist? config_file_path + load_config_file unless File.exist? unique_config_file_path # (re)load config file if it was updated or wasn't ever loaded into this process reload_config_file if config_file_updated? @@ -251,7 +280,7 @@ def config_module # return config module # rubocop:disable Security/Eval - eval config_module_name + eval unique_config_module_name # rubocop:enable Security/Eval end @@ -279,7 +308,8 @@ def writeup_is_url? end def writeup_is_file? - is_file? writeup + # Ensure that writeup lies within the assessment folder + writeup.present? && Archive.in_dir?(writeup_path, folder_path) && is_file?(writeup) end def handout_is_url? @@ -287,7 +317,8 @@ def handout_is_url? end def handout_is_file? - is_file? handout + # Ensure that handout lies within the assessment folder + handout.present? && Archive.in_dir?(handout_path, folder_path) && is_file?(handout) end # raw_score @@ -337,8 +368,13 @@ def has_handout? overwrites_method?(:handout) || handout_is_url? || handout_is_file? end - def groups - Group.joins(:assessment_user_data).where(assessment_user_data: { assessment_id: id }).distinct + def groups(show_members: false) + if show_members + Group.includes(assessment_user_data: { course_user_datum: :user }) + .where(assessment_user_data: { assessment_id: id }) + else + Group.joins(:assessment_user_data).where(assessment_user_data: { assessment_id: id }).distinct + end end def grouplessCUDs @@ -375,17 +411,30 @@ def source_config_file_path Rails.root.join("courses", course.name, sanitized_name, "#{sanitized_name}.rb") end + # name is already sanitized during the creation process + def unique_source_config_file_path + Rails.root.join("courses", course.name, name, "#{name}.rb") + end + + def source_config_file_backup_path + source_config_file_path.sub_ext(".rb.bak") + end + + def date_to_s(date) + date.strftime("%b %e at %l:%M%P") + end + private def saved_change_to_grade_related_fields? - (saved_change_to_due_at? or saved_change_to_max_grace_days? or - saved_change_to_version_threshold? or - saved_change_to_late_penalty_id? or - saved_change_to_version_penalty_id?) + saved_change_to_due_at? or saved_change_to_max_grace_days? or + saved_change_to_version_threshold? or + saved_change_to_late_penalty_id? or + saved_change_to_version_penalty_id? end def saved_change_to_due_at_or_max_grace_days? - (saved_change_to_due_at? or saved_change_to_max_grace_days?) + saved_change_to_due_at? or saved_change_to_max_grace_days? end def path(filename) @@ -404,21 +453,26 @@ def reload_config_file # remove the previously loaded config module Object.send :remove_const, config_module_name if Object.const_defined? config_module_name + if Object.const_defined? unique_config_module_name + Object.send :remove_const, + unique_config_module_name + end + # force load config file (see http://www.ruby-doc.org/core-2.0.0/Kernel.html#method-i-load) - load config_file_path + load unique_config_file_path # updated last loaded time - @@CONFIG_FILE_LAST_LOADED[config_file_path] = Time.current + @@CONFIG_FILE_LAST_LOADED[unique_config_file_path] = Time.current - logger.info "Reloaded #{config_file_path}" + logger.info "Reloaded #{unique_config_file_path}" end def config_file_updated? # config file last modified time - config_file_mtime = File.mtime config_file_path + config_file_mtime = File.mtime unique_config_file_path # get last loaded time of config file by this process - last_loaded_time = @@CONFIG_FILE_LAST_LOADED[config_file_path] + last_loaded_time = @@CONFIG_FILE_LAST_LOADED[unique_config_file_path] # if there isn't last loaded time, consider config file updated last_loaded_time ? config_file_mtime >= last_loaded_time : true @@ -468,12 +522,11 @@ def serialize # can do so easily s["dates"] = { start_at: start_at.to_s, due_at: due_at.to_s, - end_at: end_at.to_s, - grading_deadline: grading_deadline.to_s }.deep_stringify_keys + end_at: end_at.to_s }.deep_stringify_keys s end - GENERAL_SERIALIZABLE = Set.new %w[name display_name category_name description handin_filename + GENERAL_SERIALIZABLE = Set.new %w[display_name category_name description handin_filename handin_directory has_svn has_lang max_grace_days handout writeup max_submissions disable_handins max_size version_threshold is_positive_grading embedded_quiz group_size @@ -485,22 +538,20 @@ def serialize_general end def deserialize(s) - unless s["general"] && (s["general"]["name"] == name) - raise "Name in yaml (#{s['general']['name']}) doesn't match #{name}" + unless s["general"] + raise "General section missing in yaml" end if s["dates"] && s["dates"]["start_at"] - if s["dates"]["due_at"] && s["dates"]["end_at"] && s["dates"]["grading_deadline"] + if s["dates"]["due_at"] && s["dates"]["end_at"] self.due_at = Time.zone.parse(s["dates"]["due_at"]) self.start_at = Time.zone.parse(s["dates"]["start_at"]) self.end_at = Time.zone.parse(s["dates"]["end_at"]) - self.grading_deadline = Time.zone.parse(s["dates"]["grading_deadline"]) else - self.due_at = self.end_at = self.start_at = self.grading_deadline = - Time.zone.parse(s["dates"]["start_at"]) + self.due_at = self.end_at = self.start_at = Time.zone.parse(s["dates"]["start_at"]) end else - self.due_at = self.end_at = self.start_at = self.grading_deadline = Time.current + 1.day + self.due_at = self.end_at = self.start_at = Time.current + 1.day end self.quiz = false @@ -538,13 +589,12 @@ def max_score! end def is_file?(name) - name.present? && File.file?(path(name)) + File.file?(path(name)) end def verify_dates_order errors.add :due_at, "must be after the start date" if start_at > due_at errors.add :end_at, "must be after the due date" if due_at > end_at - errors.add :grading_deadline, "must be after the end date" if end_at > grading_deadline end def handin_directory_and_filename_or_disable_handins @@ -577,6 +627,27 @@ def handin_directory_exists_or_disable_handins end end + def valid_handout + return true if handout.blank? || handout_is_url? || handout_is_file? + + errors.add :handout, "must be a URL or a file in the assessment folder" + false + end + + def valid_writeup + return true if writeup.blank? || writeup_is_url? || writeup_is_file? + + errors.add :writeup, "must be a URL or a file in the assessment folder" + false + end + + def valid_handin_directory + return true if handin_directory.blank? || Archive.in_dir?(handin_directory_path, folder_path) + + errors.add :handin_directory, "must be a directory in the assessment folder" + false + end + def invalidate_raw_scores # key-based invalidation (see submission.raw_score) # rubocop:disable Rails/SkipsModelValidations @@ -585,7 +656,7 @@ def invalidate_raw_scores end def sanitized_name - name.gsub(/\./, "") + name.gsub(/[.-]/, "") end def active? diff --git a/app/models/assessment_user_datum.rb b/app/models/assessment_user_datum.rb index 907a5ce73..13e8f4eae 100755 --- a/app/models/assessment_user_datum.rb +++ b/app/models/assessment_user_datum.rb @@ -74,11 +74,11 @@ def update_latest_submission # Calculate latest unignored submission (i.e. with max version and unignored) def latest_submission! - if (max_version = Submission.where(assessment_id: assessment_id, - course_user_datum_id: course_user_datum_id, + if (max_version = Submission.where(assessment_id:, + course_user_datum_id:, ignored: false).maximum(:version)) - Submission.find_by(version: max_version, assessment_id: assessment_id, - course_user_datum_id: course_user_datum_id) + Submission.find_by(version: max_version, assessment_id:, + course_user_datum_id:) end end @@ -108,12 +108,6 @@ def final_score(as_seen_by) @final_score[as_seen_by] ||= final_score! as_seen_by end - def final_score_ignore_grading_deadline(as_seen_by) - @final_score_ignore_grading_deadline ||= {} - @final_score_ignore_grading_deadline[as_seen_by] ||= - final_score_ignore_grading_deadline! as_seen_by - end - def status(as_seen_by) @status ||= {} @status[as_seen_by] ||= status! as_seen_by @@ -201,7 +195,7 @@ def at_submission_limit? if assessment.max_submissions == -1 false else - count = assessment.submissions.where(course_user_datum: course_user_datum).count + count = assessment.submissions.where(course_user_datum:).count count >= assessment.max_submissions end end @@ -215,11 +209,11 @@ def past_end_at?(as_of = Time.current) end def extension - Extension.find_by(course_user_datum: course_user_datum, assessment_id: assessment_id) + Extension.find_by(course_user_datum:, assessment_id:) end def self.get(assessment_id, cud_id) - find_by assessment_id: assessment_id, course_user_datum_id: cud_id + find_by assessment_id:, course_user_datum_id: cud_id end # Quickly create an AUD (without any callbacks, validations, AR object creation, etc.) @@ -238,6 +232,20 @@ def global_cumulative_grace_days_used cumulative_grace_days_used end + # atomic way of updating version number + # (necessary in the case multiple submissions made concurrently) + def update_version_number + with_lock do + if version_number.nil? + self.version_number = 1 + else + self.version_number += 1 + end + save! + end + self.version_number + end + protected def cumulative_grace_days_used @@ -254,7 +262,7 @@ def cgdub_cache_key private def saved_change_to_latest_submission_id_or_grade_type? - (saved_change_to_latest_submission_id? or saved_change_to_grade_type?) + saved_change_to_latest_submission_id? or saved_change_to_grade_type? end # Applies given extension to given date limit (due date or end_at). @@ -293,23 +301,6 @@ def cumulative_grace_days_used_before # TODO: CA's shouldn't see non-normal # TODO: make above policy def final_score!(as_seen_by) - case grade_type - when NORMAL - if Time.current <= assessment.grading_deadline - nil - elsif latest_submission - latest_submission.final_score as_seen_by - else - 0.0 - end - when ZEROED - 0.0 - when EXCUSED - nil - end - end - - def final_score_ignore_grading_deadline!(as_seen_by) case grade_type when NORMAL if latest_submission diff --git a/app/models/attachment.rb b/app/models/attachment.rb index aaa62adb3..a50edffeb 100755 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -6,27 +6,40 @@ # class Attachment < ApplicationRecord validates :name, presence: true + validates :category_name, presence: true validates :filename, presence: true + validates :release_at, presence: true + validate :file_size_limit + has_one_attached :attachment_file belongs_to :course belongs_to :assessment + def file_size_limit + return unless attachment_file.attached? && attachment_file.byte_size > 1.gigabyte + + errors.add(:attachment_file, "must be less than 1GB") + end + + # Constants + ORDERING = "release_at ASC, name ASC".freeze + + # Scopes + scope :ordered, -> { order(ORDERING) } + scope :from_category, ->(category_name) { where(category_name:) } + scope :released, -> { where("release_at <= ?", Time.current) } + + def has_assessment? + !assessment.nil? + end + + def released? + release_at <= Time.current + end + def file=(upload) - directory = "attachments" - filename = File.basename(upload.original_filename) - dir_path = Rails.root.join(directory) - FileUtils.mkdir_p(dir_path) unless File.exist?(dir_path) - - path = Rails.root.join(directory, filename) - addendum = 1 - - # Deal with duplicate filenames on disk - while File.exist?(path) - path = Rails.root.join(directory, "#{filename}.#{addendum}") - addendum += 1 - end - self.filename = File.basename(path) - File.open(path, "wb") { |f| f.write(upload.read) } + self.filename = File.basename(upload.original_filename) + attachment_file.attach(upload) self.mime_type = upload.content_type end diff --git a/app/models/course.rb b/app/models/course.rb index ef261cd33..d10ead4b2 100755 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -49,7 +49,7 @@ def directory_path # Create a course with name, semester, and instructor email # all other fields are filled in automatically def self.quick_create(unique_name, semester, instructor_email) - newCourse = Course.new(name: unique_name, semester: semester) + newCourse = Course.new(name: unique_name, semester:) newCourse.display_name = newCourse.name # fill temporary values in other fields @@ -156,7 +156,7 @@ def temporal_status(now = DateTime.now) end def current_assessments(now = DateTime.now) - assessments.where("start_at < :now AND end_at > :now", now: now) + assessments.where("start_at < :now AND end_at > :now", now:) end def full_name @@ -305,17 +305,17 @@ def watchlist_allow_ca private def saved_change_to_grade_related_fields? - (saved_change_to_late_slack? or saved_change_to_grace_days? or - saved_change_to_version_threshold? or saved_change_to_late_penalty_id? or - saved_change_to_version_penalty_id?) + saved_change_to_late_slack? or saved_change_to_grace_days? or + saved_change_to_version_threshold? or saved_change_to_late_penalty_id? or + saved_change_to_version_penalty_id? end def grace_days_or_late_slack_changed? - (grace_days_changed? or late_slack_changed?) + grace_days_changed? or late_slack_changed? end def saved_change_to_grace_days_or_late_slack? - (saved_change_to_grace_days? or saved_change_to_late_slack?) + saved_change_to_grace_days? or saved_change_to_late_slack? end def cgdub_dependencies_updated diff --git a/app/models/course_user_datum.rb b/app/models/course_user_datum.rb index 342e43767..17285a61b 100755 --- a/app/models/course_user_datum.rb +++ b/app/models/course_user_datum.rb @@ -208,10 +208,10 @@ def global_grace_days_left # find a cud in the course def self.find_cud_for_course(course, uid) user = User.find(uid) - cud = user.course_user_data.find_by(course: course) + cud = user.course_user_data.find_by(course:) if cud.nil? if user.administrator? - new_cud = course.course_user_data.new(user: user, + new_cud = course.course_user_data.new(user:, instructor: true, course_assistant: true) new_cud.save ? new_cud : nil @@ -226,12 +226,12 @@ def self.find_cud_for_course(course, uid) def self.find_or_create_cud_for_course(course, uid) user = User.find(uid) - cud = user.course_user_data.find_by(course: course) + cud = user.course_user_data.find_by(course:) if cud [cud, :found] elsif user.administrator? - new_cud = course.course_user_data.new(user: user, + new_cud = course.course_user_data.new(user:, instructor: true, course_assistant: true) diff --git a/app/models/github_integration.rb b/app/models/github_integration.rb index 51c5e9b56..c0461bb47 100644 --- a/app/models/github_integration.rb +++ b/app/models/github_integration.rb @@ -1,6 +1,6 @@ class GithubIntegration < ApplicationRecord belongs_to :user - encrypts :access_token + has_encrypted :access_token # Returns the top 30 most recently pushed repos # Reasonably if a user wants to submit code, it should be among @@ -10,7 +10,7 @@ def repositories return nil end - client = Octokit::Client.new(access_token: access_token) + client = Octokit::Client.new(access_token:) begin repos = client.repos({}, @@ -38,7 +38,7 @@ def branches(repo) return nil end - client = Octokit::Client.new(access_token: access_token) + client = Octokit::Client.new(access_token:) branches = client.branches(repo, query: { per_page: 100 }) branches.map { |branch| { name: branch[:name] } @@ -52,7 +52,7 @@ def commits(repo, branch) return nil end - client = Octokit::Client.new(access_token: access_token) + client = Octokit::Client.new(access_token:) commits = client.commits(repo, query: { per_page: 100, sha: branch }) commits.map { |commit| { sha: commit[:sha].truncate(7, omission: ""), @@ -71,7 +71,7 @@ def is_connected # repo_branch should be a valid branch of repo_name # max_size is in MB def clone_repo(repo_name, repo_branch, commit, max_size) - client = Octokit::Client.new(access_token: access_token) + client = Octokit::Client.new(access_token:) repo_info = client.repo(repo_name) if !repo_info diff --git a/app/models/grade_matrix.rb b/app/models/grade_matrix.rb index e8fc3c52b..d0ae7917e 100755 --- a/app/models/grade_matrix.rb +++ b/app/models/grade_matrix.rb @@ -25,10 +25,6 @@ def last_updated @matrix["last_updated"] end - def before_grading_deadline?(asmt_id) - @matrix["asmt_before_grading_deadline"][asmt_id.to_s] - end - def cell(asmt_id, cud_id) @matrix["cell_by_asmt"][asmt_id.to_s][cud_id.to_s] end @@ -84,11 +80,6 @@ def matrix! cell_by_asmt = {} cat_avg_by_cat = {} course_avg_by_user = {} - asmt_before_grading_deadline = {} - - @course.assessments.each do |a| - asmt_before_grading_deadline[a.id.to_s] = a.before_grading_deadline? - end @course.course_user_data.each do |cud| next unless cud.student? @@ -113,7 +104,6 @@ def matrix! "cell_by_asmt" => cell_by_asmt, "cat_avg_by_cat" => cat_avg_by_cat, "course_avg_by_user" => course_avg_by_user, - "asmt_before_grading_deadline" => asmt_before_grading_deadline, "last_updated" => Time.current } end @@ -122,7 +112,7 @@ def summarize(aud) info = {} info["status"] = aud.status @as_seen_by - + info["version"] = aud.latest_submission&.version info["final_score"] = aud.final_score @as_seen_by info["grade_type"] = (AssessmentUserDatum.grade_type_to_sym aud.grade_type).to_s info["submission_status"] = aud.submission_status.to_s diff --git a/app/models/oauth_device_flow_request.rb b/app/models/oauth_device_flow_request.rb index 45124b636..976c2ede3 100644 --- a/app/models/oauth_device_flow_request.rb +++ b/app/models/oauth_device_flow_request.rb @@ -3,8 +3,8 @@ class OauthDeviceFlowRequest < ApplicationRecord validates :device_code, uniqueness: { on: :create } validates :user_code, uniqueness: { on: :create } - validates :requested_at, presence: { on: :create } - validates_associated :oauth_application, on: :create + validates :requested_at, presence: { on: :create } + validates_associated :oauth_application, on: :create # disallow others from instantiating requests on their own. # Must use create_request to create new device flow requests. @@ -25,8 +25,8 @@ def self.create_request(app) req = new(application_id: app.id, scopes: app.scopes, requested_at: Time.current, - device_code: device_code, - user_code: user_code) + device_code:, + user_code:) # success return req if req.save diff --git a/app/models/risk_condition.rb b/app/models/risk_condition.rb index 8786fffe2..ee4fc472e 100644 --- a/app/models/risk_condition.rb +++ b/app/models/risk_condition.rb @@ -27,8 +27,8 @@ def self.create_condition_for_course_with_type(course_id, type, params, version) "Make sure your request body fits the criteria!" end - options = { course_id: course_id, condition_type: type, parameters: params.to_hash, - version: version } + options = { course_id:, condition_type: type, parameters: params.to_hash, + version: } new_risk_condition = RiskCondition.new(options) unless new_risk_condition.save raise "Fail to create new risk condition with type #{type} for course #{course_id}" @@ -43,7 +43,7 @@ def self.get_current_for_course(course_name) return [] if max_version == 0 - conditions_for_course = RiskCondition.where(course_id: course_id, version: max_version) + conditions_for_course = RiskCondition.where(course_id:, version: max_version) condition_types = conditions_for_course.map { |condition| condition.condition_type.to_sym } if condition_types.any? { |type| type == :no_condition_selected } [] @@ -65,7 +65,7 @@ def self.update_current_for_course(course_name, params) return [] end - previous_conditions = RiskCondition.where(course_id: course_id, version: max_version) + previous_conditions = RiskCondition.where(course_id:, version: max_version) previous_types = previous_conditions.map(&:condition_type) if params.empty? if !previous_types.empty? && previous_types.none? { |t| t == "no_condition_selected" } @@ -177,7 +177,7 @@ def self.get_no_submissions_condition_for_course(course_name) end def self.get_max_version(course_id) - conditions_for_course = RiskCondition.where(course_id: course_id) + conditions_for_course = RiskCondition.where(course_id:) versions = conditions_for_course.map(&:version) max_version = versions.max if max_version.nil? diff --git a/app/models/score.rb b/app/models/score.rb index 3de7883e3..e5f9e4503 100755 --- a/app/models/score.rb +++ b/app/models/score.rb @@ -12,7 +12,7 @@ class Score < ApplicationRecord } def self.for_course(course_id) - where(assessments: { course_id: course_id }).joins(submission: :assessment) + where(assessments: { course_id: }).joins(submission: :assessment) end delegate :invalidate_raw_score, to: :submission @@ -34,10 +34,10 @@ def self.find_or_initialize_by_submission_id_and_problem_id(submission_id, probl raise InvalidScoreException.new, "submission_id and problem_id cannot be empty" end - score = Score.find_by(submission_id: submission_id, problem_id: problem_id) + score = Score.find_by(submission_id:, problem_id:) if !score - Score.new(submission_id: submission_id, problem_id: problem_id) + Score.new(submission_id:, problem_id:) else score end @@ -65,6 +65,6 @@ def log_entry private def saved_change_to_score_or_released? - (saved_change_to_score? or saved_change_to_released?) + saved_change_to_score? or saved_change_to_released? end end diff --git a/app/models/score_adjustment.rb b/app/models/score_adjustment.rb index 979eeeb74..6ed93118c 100755 --- a/app/models/score_adjustment.rb +++ b/app/models/score_adjustment.rb @@ -64,13 +64,13 @@ def kind def to_s case self[:kind] when POINTS - type_str = "points" + type_str = " points" when PERCENT type_str = "%" else raise ArgumentError end - "#{format('%+g', value)} #{type_str}" + "#{format('%g', value)}#{type_str}" end end diff --git a/app/models/submission.rb b/app/models/submission.rb index 9045477f6..fb06cf584 100755 --- a/app/models/submission.rb +++ b/app/models/submission.rb @@ -20,7 +20,6 @@ class Submission < ApplicationRecord validate :allowed?, on: :create validates_associated :assessment - validates :version, uniqueness: { scope: %i[course_user_datum_id assessment_id] } validate :user_and_assessment_in_same_course validates :notes, length: { maximum: 255 } @@ -168,11 +167,13 @@ def handin_file_long_filename "#{course_user_datum.email}_#{version}_#{assessment.handin_filename}" end + def new_handin_file_path + File.join(assessment.handin_directory_path, course_user_datum.email, filename) + end + def handin_file_path return nil unless filename - new_handin_file_path = File.join(assessment.handin_directory_path, course_user_datum.email, - filename) old_handin_file_path = File.join(assessment.handin_directory_path, filename) unless File.exist?(old_handin_file_path) return new_handin_file_path @@ -185,8 +186,7 @@ def create_user_directory_and_return_handin_file_path return nil unless filename create_user_handin_directory - - File.join(assessment.handin_directory_path, course_user_datum.email, filename) + new_handin_file_path end def handin_annotated_file_path @@ -211,10 +211,12 @@ def old_autograde_feedback_filename "#{course_user_datum.email}_#{version}_#{assessment.name}_autograde.txt" end + def new_autograde_feedback_path + File.join(assessment.handin_directory_path, course_user_datum.email, + autograde_feedback_filename) + end + def autograde_feedback_path - new_autograde_feedback_path = File.join(assessment.handin_directory_path, - course_user_datum.email, - autograde_feedback_filename) old_autograde_feedback_path = File.join(assessment.handin_directory_path, old_autograde_feedback_filename) unless File.exist?(old_autograde_feedback_path) @@ -224,6 +226,11 @@ def autograde_feedback_path old_autograde_feedback_path end + def create_user_directory_and_return_autograde_feedback_path + create_user_handin_directory + new_autograde_feedback_path + end + def autograde_file path = autograde_feedback_path return nil unless path @@ -247,7 +254,7 @@ def handin_file end def annotated_file(file, filename, position) - conditions = { filename: filename } + conditions = { filename: } conditions[:position] = position if position annotations = self.annotations.where(conditions) @@ -271,14 +278,7 @@ def user_and_assessment_in_same_course def set_version self.submitted_by_id = course_user_datum_id unless submitted_by_id - begin - if version != 0 - self.version = 1 + assessment.submissions.where(course_user_datum: - course_user_datum).maximum(:version) - end - rescue TypeError - self.version = 1 - end + self.version = aud.update_version_number end def problems_to_scores @@ -344,7 +344,7 @@ def version_over_threshold_by # normal submission versions start at 1 # unofficial submissions conveniently have version 0 # actual version number is not used here, instead submission count is used - count = assessment.submissions.where(course_user_datum: course_user_datum).count + count = assessment.submissions.where(course_user_datum:).count [count - assessment.effective_version_threshold, 0].max end @@ -399,26 +399,10 @@ def as_json(options = {}) json end - def grading_complete?(as_seen_by) - include_unreleased = !as_seen_by.student? - - complete, released = scores_status - (released || include_unreleased) && complete - end - - def scores_status - all_complete = true - all_released = true - - problems_to_scores.each do |problem, score| - next if problem.optional? - return false unless score - - all_complete &&= false unless score.score - all_released &&= score.released? - end - - [all_complete, all_released] + def grades_released?(as_seen_by) + include_unreleased = as_seen_by.course_assistant? || as_seen_by.instructor? + released = scores.pluck(:released).all? + released || include_unreleased end # easy access to AUD @@ -429,7 +413,7 @@ def aud def group_associated_submissions raise "Submission is not associated with a group" if group_key.empty? - Submission.where(group_key: group_key).where.not(id: id) + Submission.where(group_key:).where.not(id:) end private @@ -545,13 +529,13 @@ def allowed? else case why_not when :user_dropped - errors[:base] << "You cannot submit because you have dropped the course." + errors.add(:base, "You cannot submit because you have dropped the course.") when :before_start_at - errors[:base] << "We are not yet accepting submissions on this assessment." + errors.add(:base, "We are not yet accepting submissions on this assessment.") when :past_end_at - errors[:base] << "You cannot submit because it is past the deadline." + errors.add(:base, "You cannot submit because it is past the deadline.") when :at_submission_limit - errors[:base] << "You you have already reached the submission limit." + errors.add(:base, "You have already reached the submission limit.") else raise "FATAL: unknown reason for submission denial" end diff --git a/app/models/user.rb b/app/models/user.rb old mode 100755 new mode 100644 index b3aeab189..3649fb1c4 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -35,7 +35,7 @@ def instructor_of?(user) cuds.each do |cud| next unless cud.instructor? - return true unless cud.course.course_user_data.where(user: user).empty? + return true unless cud.course.course_user_data.where(user:).empty? end false @@ -84,19 +84,19 @@ def ldap_reset def self.find_for_facebook_oauth(auth, _signed_in_resource = nil) authentication = Authentication.find_by(provider: auth.provider, uid: auth.uid) - return authentication.user if authentication&.user + authentication.user if authentication&.user end def self.find_for_google_oauth2_oauth(auth, _signed_in_resource = nil) authentication = Authentication.find_by(provider: auth.provider, uid: auth.uid) - return authentication.user if authentication&.user + authentication.user if authentication&.user end def self.find_for_shibboleth_oauth(auth, _signed_in_resource = nil) authentication = Authentication.find_by(provider: "CMU-Shibboleth", uid: auth.uid) - return authentication.user if authentication&.user + authentication.user if authentication&.user end def self.new_with_session(params, session) @@ -142,9 +142,6 @@ def self.roster_create(email, first_name, last_name, school, major, year) user.password_confirmation = temp_pass user.skip_confirmation! - Rails.logger.debug("user email: #{user.email}") - Rails.logger.debug("user pswd: #{user.password}") - user.save! user end @@ -184,7 +181,7 @@ def self.ldap_lookup(andrew_id) require "net/ldap" host = "ldap.cmu.edu" - ldap = Net::LDAP.new(host: host, port: 389) + ldap = Net::LDAP.new(host:, port: 389) user = ldap.search(base: "uid=#{andrew_id},ou=AndrewPerson,dc=andrew,dc=cmu,dc=edu")[0] diff --git a/app/models/watchlist_instance.rb b/app/models/watchlist_instance.rb index 2ca55267e..09424c0d2 100644 --- a/app/models/watchlist_instance.rb +++ b/app/models/watchlist_instance.rb @@ -10,7 +10,7 @@ def self.get_instances_for_course(course_name) rescue NoMethodError raise "Course #{course_name} cannot be found" end - WatchlistInstance.where(course_id: course_id) + WatchlistInstance.where(course_id:) end def self.get_num_pending_instance_for_course(course_name) @@ -19,7 +19,7 @@ def self.get_num_pending_instance_for_course(course_name) rescue NoMethodError raise "Course #{course_name} cannot be found" end - WatchlistInstance.where(course_id: course_id, + WatchlistInstance.where(course_id:, status: :pending).distinct.count(:course_user_datum_id) end @@ -510,7 +510,7 @@ def self.add_new_instance_for_cud_grace_day_usage(course, condition_id, WatchlistInstance.new(course_user_datum_id: cud.id, course_id: course.id, risk_condition_id: condition_id, - violation_info: violation_info) + violation_info:) end def self.add_new_instance_for_cud_extension_requests(course, category_blocklist, @@ -542,7 +542,7 @@ def self.add_new_instance_for_cud_extension_requests(course, category_blocklist, WatchlistInstance.new(course_user_datum_id: cud.id, course_id: course.id, risk_condition_id: condition_id, - violation_info: violation_info) + violation_info:) end def self.add_new_instance_for_cud_grade_drop(course, @@ -565,8 +565,8 @@ def self.add_new_instance_for_cud_grade_drop(course, while i + consecutive_counts - 1 < auds.count begin_aud = auds[i] end_aud = auds[i + consecutive_counts - 1] - begin_grade = begin_aud.final_score_ignore_grading_deadline(cud) - end_grade = end_aud.final_score_ignore_grading_deadline(cud) + begin_grade = begin_aud.final_score(cud) + end_grade = end_aud.final_score(cud) if begin_grade.nil? || end_grade.nil? # - Either is excused i += 1 @@ -606,7 +606,7 @@ def self.add_new_instance_for_cud_grade_drop(course, WatchlistInstance.new(course_user_datum_id: cud.id, course_id: course.id, risk_condition_id: condition_id, - violation_info: violation_info) + violation_info:) end def self.add_new_instance_for_cud_no_submissions(course, @@ -629,7 +629,7 @@ def self.add_new_instance_for_cud_no_submissions(course, WatchlistInstance.new(course_user_datum_id: cud.id, course_id: course.id, risk_condition_id: condition_id, violation_info: { - no_submissions_asmt_names: no_submissions_asmt_names + no_submissions_asmt_names: }) end @@ -644,7 +644,7 @@ def self.add_new_instance_for_cud_low_grades(course, auds = AssessmentUserDatum.where(assessment_id: asmts_ids, course_user_datum_id: cud.id) violation_info = {} auds.each do |aud| - aud_score = aud.final_score_ignore_grading_deadline(cud) + aud_score = aud.final_score(cud) # - Score is excused # - Score has not been released yet # - Student did not make any submissions at all @@ -663,6 +663,6 @@ def self.add_new_instance_for_cud_low_grades(course, WatchlistInstance.new(course_user_datum_id: cud.id, course_id: course.id, risk_condition_id: condition_id, - violation_info: violation_info) + violation_info:) end end diff --git a/app/views/announcements/_announcement.html.erb b/app/views/announcements/_announcement.html.erb index 60bf44dd0..2757ee887 100755 --- a/app/views/announcements/_announcement.html.erb +++ b/app/views/announcements/_announcement.html.erb @@ -5,8 +5,8 @@ <%= link_to [:edit, @course, announcement] do %> Edit <% end %> - <%= link_to [@course, announcement], method: :delete, - data: {confirm: "Are you sure you want to delete this announcement?"} do %> + <%= link_to [@course, announcement], method: :delete, + data: { confirm: "Are you sure you want to delete this announcement?" } do %> Delete <% end %> <% end %> diff --git a/app/views/announcements/_announcementFields.html.erb b/app/views/announcements/_announcementFields.html.erb index fe28eeaaf..85766b881 100755 --- a/app/views/announcements/_announcementFields.html.erb +++ b/app/views/announcements/_announcementFields.html.erb @@ -4,26 +4,25 @@ <% end %> <%= f.text_field :title, - help_text: "The best titles are short and sweet." %> + help_text: "The best titles are short and sweet." %> <%= f.text_area :description, class: "materialize-textarea", - help_text: "There'll also be limited space for the description text. If you need something long-form, perhaps link to a longer announcement posted on external media." %> + help_text: "There'll also be limited space for the description text. If you need something long-form, perhaps link to a longer announcement posted on external media." %> <%= f.datetime_select :start_date, - help_text: "When should the announcement appear?", - less_than: "announcement_end_date" %> + help_text: "When should the announcement appear?", + less_than: "announcement_end_date" %> <%= f.datetime_select :end_date, - help_text: "When should the announcement come down?", - greater_than: "announcement_start_date" %> + help_text: "When should the announcement come down?", + greater_than: "announcement_start_date" %> - -<%= f.check_box :persistent, - help_text: "A persistent announcement is shown on every page in the course." %> +<%= f.check_box :persistent, display_name: "Make persistent", + help_text: "A persistent announcement is shown on every page in the course." %> <% if @cud.administrator? then %> - <%= f.check_box :system, - help_text: "Check this to make a system-wide announcement." %> + <%= f.check_box :system, display_name: "Make system-wide", + help_text: "Check this to make a system-wide announcement." %> <% end %> <%= f.submit 'Submit' %> diff --git a/app/views/announcements/edit.html.erb b/app/views/announcements/edit.html.erb index 2c1fe60f2..3a6a014e3 100755 --- a/app/views/announcements/edit.html.erb +++ b/app/views/announcements/edit.html.erb @@ -1,8 +1,9 @@

Edit Announcement

-
- <%= form_for @announcement, url: course_announcement_path(@course, @announcement), method: :patch, builder: FormBuilderWithDateTimeInput do |f| %> - <%= render partial: 'announcementFields', locals: {f:f} %> +
+ <%= form_for @announcement, url: course_announcement_path(@course, @announcement), + method: :patch, builder: FormBuilderWithDateTimeInput do |f| %> + <%= render partial: 'announcementFields', locals: { f: } %> <% end %>
diff --git a/app/views/announcements/index.html.erb b/app/views/announcements/index.html.erb index a0c5cbbf5..50bd0416f 100755 --- a/app/views/announcements/index.html.erb +++ b/app/views/announcements/index.html.erb @@ -1,8 +1,14 @@ -

Make Announcements

+

Manage Announcements

+

Current Announcements

    -<%= render @announcements %> + <% if !@announcements.nil? && @announcements.length > 0 %> + <%= render @announcements %> + <% else %> + There are no existing announcements. + <% end %>
-<%= link_to raw('Create Announcement'), - new_course_announcement_path(@course) %> -
-<%= link_to raw('Save All Changes'), @course %> +<%= link_to new_course_announcement_path(@course) do %> + + Create Announcement + +<% end %> diff --git a/app/views/announcements/new.html.erb b/app/views/announcements/new.html.erb index f81eb0142..6218bcb8b 100755 --- a/app/views/announcements/new.html.erb +++ b/app/views/announcements/new.html.erb @@ -1,8 +1,8 @@

Create Announcement

-
- <%= form_for @announcement, url:{action:"create"}, builder: FormBuilderWithDateTimeInput do |f| %> - <%= render partial: 'announcementFields', locals: {f:f} %> +
+ <%= form_for @announcement, url: { action: "create" }, builder: FormBuilderWithDateTimeInput do |f| %> + <%= render partial: 'announcementFields', locals: { f: } %> <% end %>
diff --git a/app/views/assessments/_edit_basic.html.erb b/app/views/assessments/_edit_basic.html.erb index e2bd3c217..c9a72d83d 100755 --- a/app/views/assessments/_edit_basic.html.erb +++ b/app/views/assessments/_edit_basic.html.erb @@ -29,9 +29,13 @@ placeholder: "E.g. writeup.pdf or http://school.edu/class/writeup.pdf" %>
+

Assessment Config

<%= f.file_field :config_file, button_text: "Upload #{@assessment.name}.rb", - help_text: "Config file will be automatically reloaded after saving. Changes to other settings will - be lost if an error is encountered." %> + help_text: "Config file will be automatically reloaded after saving. Changes to other settings will be lost if an error is encountered." %> + +
+ <%= link_to "Reload Assessment Config", { action: :reload }, { method: :post, class: "btn", title: "Reload the Assessment Config in the case that a config file doesn't exist." } %> +

Modules Used

    diff --git a/app/views/assessments/_edit_handin.html.erb b/app/views/assessments/_edit_handin.html.erb index 8e64f0380..ac249c0a3 100644 --- a/app/views/assessments/_edit_handin.html.erb +++ b/app/views/assessments/_edit_handin.html.erb @@ -5,22 +5,16 @@ <%= f.datetime_select :start_at, style: "margin-top: 0 !important;", help_text: "The time this assessment is released to students.", - less_than: "assessment_due_at assessment_end_at assessment_grading_deadline" %> + less_than: "assessment_due_at assessment_end_at" %> <%= f.datetime_select :due_at, style: "margin-top: 0 !important;", help_text: "Students can submit before this time without being penalized or using grace days.", greater_than: "assessment_start_at", - less_than: "assessment_end_at assessment_grading_deadline" %> + less_than: "assessment_end_at" %> <%= f.datetime_select :end_at, style: "margin-top: 0 !important;", help_text: "Last possible time that students can submit (except those granted extensions.)", - greater_than: "assessment_start_at assessment_due_at", - less_than: "assessment_grading_deadline" %> -<%= f.datetime_select :grading_deadline, - style: "margin-top: 0 !important;", - help_text: "Time after which final scores are included in the gradebook", - greater_than: "assessment_start_at assessment_due_at assessment_end_at", - placeholder: Date.current %> + greater_than: "assessment_start_at assessment_due_at" %> <% if GithubIntegration.connected %> <%= f.check_box :github_submission_enabled, diff --git a/app/views/assessments/_edit_penalties.html.erb b/app/views/assessments/_edit_penalties.html.erb index 4358794ba..4c2079149 100755 --- a/app/views/assessments/_edit_penalties.html.erb +++ b/app/views/assessments/_edit_penalties.html.erb @@ -1,31 +1,55 @@ <%= f.text_field :max_submissions, - help_text: "The maximum number of times a student can submit the assessment. - Set this to -1 to allow unlimited submissions.", - placeholder: "E.g. 10" %> + help_text: "The maximum number of times a student can submit the assessment. \ +
    If set to -1, unlimited submissions are allowed.".html_safe, + placeholder: "10", + wrap_class: %w[input-field no-padding-bottom] %> +<%= label_tag(:unlimited_submissions, nil, class: "input-default") do %> + <%= check_box_tag :unlimited_submissions, "1", @has_unlimited_submissions %> + <%= content_tag("span", "Allow unlimited submissions.") %> +<% end %> <%= f.text_field :max_grace_days, - help_text: "Maximum number of grace days that a student can spend on this assessment. E.g., 2. If left - blank, all of the remaining available course grace days can be spent on this assessment.", - placeholder: "Leave blank for no grace day limit" %> + help_text: "Maximum number of grace days that a student can spend on this assessment. \ +
    If left blank, all available grace days can be spent on this assessment.".html_safe, + wrap_class: %w[input-field no-padding-bottom] %> +<%= label_tag(:unlimited_grace_days) do %> + <%= check_box_tag :unlimited_grace_days, "1", @has_unlimited_grace_days %> + <%= content_tag("span", "Allow unlimited grace days.") %> +<% end %> <%= f.score_adjustment_input :late_penalty, optional: true, help_text: "The penalty applied to late submissions after a student runs out of grace days. It represents the number of points or a percentage of the total score removed per day, and must be a non-negative - number. If left blank, the course default is used.", - placeholder: "E.g. 15%" %> + number.
    If left blank, the course default is used.".html_safe, + wrap_class: %w[input-field no-padding-bottom] %> +<%= label_tag(:use_default_late_penalty) do %> + <%= check_box_tag :use_default_late_penalty, "1", @uses_default_late_penalty %> + <%= content_tag("span", "Use the course default of deducting #{@assessment.course.late_penalty} per day.") %> +<% end %> -<%= f.text_field :version_threshold, help_text: "The number of unpenalized submissions allowed. After this threshold, - each additional submission is penalized according to the version penalty. If set to -1, no submissions are penalized. - If this is left blank, the course default is used.", - placeholder: "Leave blank to use course default." %> +<%= f.text_field :version_threshold, + help_text: "The number of unpenalized submissions allowed. After this threshold, + each additional submission is penalized according to the version penalty. +
    If set to -1, no submissions are penalized. +
    If left blank, the course default is used.".html_safe, + wrap_class: %w[input-field no-padding-bottom] %> +<%= label_tag(:use_default_version_threshold) do %> + <%= check_box_tag :use_default_version_threshold, "1", @uses_default_version_threshold %> + <%= content_tag("span", "Use the course default of #{@assessment.course.version_threshold}.") %> +<% end %> <%= f.score_adjustment_input :version_penalty, optional: true, help_text: "The penalty applied to submissions with version greater than the version threshold. It represents the number of points or the percentage of the total score removed per version above the - threshold, and must be a non-negative number. For example, if this is set to 1 point and the version threshold to 3, - the fifth version of a student's submissions would be docked 2 points.", - placeholder: "Leave blank to use course default." %> + threshold, and must be a non-negative number. + For example, if this is set to 1 point and the version threshold to 3, the fifth version of a student's submissions would be docked 2 points. +
    If left blank, the course default is used.".html_safe, + wrap_class: %w[input-field no-padding-bottom] %> +<%= label_tag(:use_default_version_penalty) do %> + <%= check_box_tag :use_default_version_penalty, "1", @uses_default_version_penalty %> + <%= content_tag("span", "Use the course default of deducting #{@assessment.course.version_penalty} per submission.") %> +<% end %>
    <%= f.submit "Save", name: "penalties" %> diff --git a/app/views/assessments/_handin_form.html.erb b/app/views/assessments/_handin_form.html.erb index 1557528ed..f7fe371dc 100644 --- a/app/views/assessments/_handin_form.html.erb +++ b/app/views/assessments/_handin_form.html.erb @@ -61,7 +61,7 @@ <%= javascript_include_tag "git_submission" %> <% end %> - <%= render partial: "submission_panel", locals: { repos: @repos, f: f } %> + <%= render partial: "submission_panel", locals: { repos: @repos, f: } %> <% filename_list = @assessment.handin_filename.split(".") %> <% if (filename_list.size > 1) %> diff --git a/app/views/assessments/_remarks_panel.html.erb b/app/views/assessments/_remarks_panel.html.erb index 327d9552b..e080652c3 100644 --- a/app/views/assessments/_remarks_panel.html.erb +++ b/app/views/assessments/_remarks_panel.html.erb @@ -1,4 +1,4 @@ -<% if @submission.annotations.count > 0 and (@cud.instructor? or @cud.course_assistant? or @problemReleased) %> +<% if @submission.annotations.count > 0 and @submission.grades_released?(@cud) %>
    diff --git a/app/views/assessments/_submission_history_row.html.erb b/app/views/assessments/_submission_history_row.html.erb index 93832ee13..2752731bd 100755 --- a/app/views/assessments/_submission_history_row.html.erb +++ b/app/views/assessments/_submission_history_row.html.erb @@ -54,9 +54,12 @@ <% end %> <% if @course.grace_days >= 0 then %> - - - <%= submission.days_late.to_s + " day".pluralize(submission.days_late) %> + + + <%= submission.days_late.to_s + " day".pluralize(submission.days_late) %> + + + (<%= submission.grace_days_used.to_s + " day".pluralize(submission.grace_days_used) %>) <% end %> diff --git a/app/views/assessments/_submission_history_table.html.erb b/app/views/assessments/_submission_history_table.html.erb index ce52ad248..97b0e1ae7 100755 --- a/app/views/assessments/_submission_history_table.html.erb +++ b/app/views/assessments/_submission_history_table.html.erb @@ -15,7 +15,7 @@ <% end %> <% if @course.grace_days >= 0 %> - Late Days Used + Days Late
    (Grace Days Used) <% end %> <% if @assessment.version_penalty? %> @@ -39,8 +39,8 @@ <% if limit.nil? then limit = @submissions.size end %> <% for submission in @submissions.first(limit) do %> <%= render partial: "submission_history_row", - locals: { submission: submission, - download_access: download_access } %> + locals: { submission:, + download_access: } %> <% end %> <% end %> diff --git a/app/views/assessments/bulkGrade.html.erb b/app/views/assessments/bulkGrade.html.erb index e4028dc60..56d1d9d92 100755 --- a/app/views/assessments/bulkGrade.html.erb +++ b/app/views/assessments/bulkGrade.html.erb @@ -10,15 +10,15 @@ <% end %> <% if @valid_entries %> - <%= render "bulkGrade_entries", :entries => @entries, :problems => @assessment.problems if @entries %> - <%= form_for :confirm, :url => "bulkGrade_complete", :html => { :class => "confirm" } do |f| %> - <%= f.hidden_field :bulkGrade_csv, :value => @csv %> - <%= f.hidden_field :bulkGrade_data_type, :value => @data_type %> + <%= render "bulkGrade_entries", entries: @entries, problems: @assessment.problems if @entries %> + <%= form_for :confirm, url: "bulkGrade_complete", html: { class: "confirm" } do |f| %> + <%= f.hidden_field :bulkGrade_csv, value: @csv %> + <%= f.hidden_field :bulkGrade_data_type, value: @data_type %> - <%= f.submit 'Yes' , {:class=>"btn submit"} %><%= link_to "No", { :action => :bulkGrade }, :class => "btn submit" %> + <%= f.submit 'Yes', { class: "btn submit" } %><%= link_to "No", { action: :bulkGrade }, class: "btn submit" %> <% end %> <% else %> - <%= render "bulkGrade_error_entries", :entries => @entries, :problems => @assessment.problems if @entries %> + <%= render "bulkGrade_error_entries", entries: @entries, problems: @assessment.problems if @entries %> <%= render 'bulkGrade_initial' %> <% end %>
    diff --git a/app/views/assessments/edit.html.erb b/app/views/assessments/edit.html.erb index b673cb17d..33fb92900 100755 --- a/app/views/assessments/edit.html.erb +++ b/app/views/assessments/edit.html.erb @@ -26,35 +26,20 @@
    <%= form_for [@course, @assessment], url: edit_course_assessment_path(@course, @assessment) + "/#{params[:active_tab]}", builder: FormBuilderWithDateTimeInput do |f| %> - <% if @course.errors.any? %> -
      - <% @course.errors.full_messages.each do |msg| %> -
    • <%= msg %>
    • - <% end %> -
    - <% end %> - - <% if @assessment.errors.any? %> -
      - <% @assessment.errors.full_messages.each do |msg| %> -
    • <%= msg %>
    • - <% end %> -
    - <% end %>
    - <%= render "edit_basic", f: f %> + <%= render "edit_basic", f: %>
    - <%= render "edit_handin", f: f %> + <%= render "edit_handin", f: %>
    - <%= render "edit_penalties", f: f %> + <%= render "edit_penalties", f: %>
    - <%= render "edit_problems", f: f %> + <%= render "edit_problems", f: %>
    - <%= render "edit_advanced", f: f %> + <%= render "edit_advanced", f: %>
    <% end %>
    diff --git a/app/views/assessments/history.html.erb b/app/views/assessments/history.html.erb index 9d1bb80c9..74350c74e 100755 --- a/app/views/assessments/history.html.erb +++ b/app/views/assessments/history.html.erb @@ -41,7 +41,7 @@ <% end %> <% end %> -<%= render partial: "submission_history_table", locals: { download_access: download_access, limit: @submissions.size } %> +<%= render partial: "submission_history_table", locals: { download_access:, limit: @submissions.size } %> <% if @partial then %> Exit diff --git a/app/views/assessments/index.html.erb b/app/views/assessments/index.html.erb index ed6d7d70c..54418acf9 100644 --- a/app/views/assessments/index.html.erb +++ b/app/views/assessments/index.html.erb @@ -4,7 +4,8 @@ /* a way to mimic nested anchor tags */ $('a.collection-item span.new.badge').each(function(idx, obj) { $(obj).on("click", function() { - window.location = obj.dataset.url; + if (obj.dataset.url) + window.location = obj.dataset.url; return false; // prevent click propagation }); }); @@ -36,6 +37,7 @@ overflow: auto; } + <%= stylesheet_link_tag "assessments" %> <% end %> <%= render partial: "announcements/announcements_list", @@ -90,48 +92,86 @@ <% end %> <% asmts = @course.assessments_with_category(cat, @cud.student?) %> <% if asmts.any? %> -
    -
    +
    +
    <%= cat %>
    -
    +
    <% asmts.each do |asmt| %> <%= link_to course_assessment_path(@course, asmt), - class: "collection-item grey-text text-darken-4" do %> - <%= asmt.display_name %> + class: "collection-item grey-text text-darken-4 date" do %> + <%= asmt.display_name %> <% if !asmt.released? %> + <% if @cud.instructor? %> + <% else %> + <%# CAs can see unreleased assessments, but can't edit them. %> + + <% end %> <% end %> +

    Start: <%= asmt.date_to_s(asmt.start_at) %> | +  Due: <%= asmt.date_to_s(asmt.due_at) %>

    <% end %> <% end %>
    -
    <% end %> <% end %> -
    <% end %> -<% if @attachments.any? or @cud.instructor? %> +<% if @course_attachments.any? or @cud.instructor? %>

    Attachments

    -
      - <% if @cud.instructor? %> + <% if @cud.instructor? %> +
      • <%= link_to new_course_attachment_path(@course) do %> note_addAdd Attachment <% end %> -

      • +
      + <% end %> +
      + <% attachment_categories = @course_attachments.distinct.pluck(:category_name).sort %> + <% attachment_categories.each_with_index do |cat, idx| %> + <% if idx % 3 == 0 %> +
      + <% end %> + <% attachments = @course_attachments.from_category(cat).ordered %> + <% if attachments.any? %> +
      +
      +
      + <%= cat %> +
      + <% if @cud.instructor? %> +
        + <%= render partial: "attachments/course_attachment", collection: attachments, as: :attachment %> +
      + <% else %> +
      + <% attachments.each do |attach| %> + <%= link_to course_attachment_path(@course, attach), + class: "collection-item grey-text text-darken-4" do %> + + file_download + <%= attach.name %> + + <% end %> + <% end %> +
      + <% end %> +
      +
      + <% end %> <% end %> - <%= render @attachments %> -
    +
    <% end %> diff --git a/app/views/assessments/show.html.erb b/app/views/assessments/show.html.erb index 88794f1a7..9f1ff7306 100755 --- a/app/views/assessments/show.html.erb +++ b/app/views/assessments/show.html.erb @@ -1,19 +1,6 @@ <% content_for :javascripts do %> - <%= javascript_include_tag 'validateIntegrity.js' %> - + <%= javascript_include_tag 'validateIntegrity' %> + <%= javascript_include_tag 'collapsible' %> <% end %> <%# Make sure these options are not on the general user options list. %> @@ -31,10 +18,10 @@ <% @list.delete("submission") %> <% @list.delete("reload") %> -
    +
    -

    +

    <%= @assessment.display_name %>

    <%= @assessment.description %>

    @@ -43,80 +30,79 @@
    - - <%# Display any optional instructor admin options %> - <% if @cud.instructor? then %> -
      -
<% end %> - <%# Display the optional CA admin options %> - <% if @cud.course_assistant? or @cud.instructor? then %> -
    -

@@ -216,19 +198,6 @@

Attachments

    - <% if false then %> - <% for a in @assessment.attachments do %> - <% if a.released? then %> - <% if (Time.now() > @assessment.start_at) or (@cud.instructor?) then %> -
  • <%= link_to a.name, [@course, @assessment, a] %>
  • - <% else %> -
  • <%= a.name %>
  • - <% end %> - <% elsif @cud.instructor? then %> -
  • <%= link_to a.name, [@course, @assessment, a] %>*
  • - <% end %> - <% end %> - <% end %> <% if @cud.instructor? %>
  • @@ -250,8 +219,8 @@ @@ -292,7 +259,7 @@


    - <%= form_for @submission, url: { action: :handin }, html: { id: "quiz_form", onclick: "return validateIntegrity();" }, method: :post do |f| %> + <%= form_for @submission, url: handin_course_assessment_path(@course, @assessment), html: { id: "quiz_form", onclick: "return validateIntegrity();" }, method: :post do |f| %> <%= f.hidden_field :embedded_quiz_form_answer, value: "" %> @@ -344,14 +311,14 @@

    Submission Summary

    - <% if @submissions.size == 0 then %> + <% if @submissions.size == 0 %> No Submissions yet! <% else %> - <%= render partial: "submission_history_table", locals: { download_access: download_access, limit: 3 } %> - <% if @submissions.size > 3 then %> + <%= render partial: "submission_history_table", locals: { download_access:, limit: 3 } %> + <% if @submissions.size > 3 %>
    - <%= link_to "See all #{@submissions.size}" + " submission".pluralize(@submissions.size), { action: "history" } %> + <%= link_to "See all #{@submissions.size}" + " submission".pluralize(@submissions.size), history_course_assessment_path(@course, @assessment) %>
    <% end %> <% end %> diff --git a/app/views/assessments/statistics.html.erb b/app/views/assessments/statistics.html.erb index d54190c1c..da1cdb9e8 100755 --- a/app/views/assessments/statistics.html.erb +++ b/app/views/assessments/statistics.html.erb @@ -1,4 +1,10 @@ <% @title = "Statistics" %> +<% if @error %> +

    Error loading your course's grading configuration file.

    +
    +
    <%= @error.to_s %>
    +
    +<% end %>

    Statistics for <%= link_to @assessment.display_name, course_assessment_path(@course, @assessment) %>

    <% if @assessment.problems.count > 0 %> diff --git a/app/views/assessments/viewGradesheet.html.erb b/app/views/assessments/viewGradesheet.html.erb index 11ba995a4..28cdb4dae 100755 --- a/app/views/assessments/viewGradesheet.html.erb +++ b/app/views/assessments/viewGradesheet.html.erb @@ -11,7 +11,7 @@ +<% end %> +<%= form_for @attachment, as: :attachment, url: path, builder: FormBuilderWithDateTimeInput do |f| %>
    • <%= f.text_field :name, required: true %>
    • + <% unless @is_assessment %> +
    • + <%= f.text_field :category_name, required: true %> +
    • + <% end %>
    • <% if @attachment.new_record? %> - <%= f.file_field :file, button_text: "Upload Attachment" %> + <%= f.file_field :file, button_text: "Upload Attachment", help_text: "Max Size: 1GB" %> <% else %> <%= f.text_field :mime_type, help_text: "Note: If you change the Mime type of a file, you might need to clear your browser's cache in order for you to see the change." %> <% end %>
    • -
      - <%= f.check_box :released, help_text: "Checking this box will release the attachment to students" %> -
      + <%= f.datetime_select :release_at, help_text: "Release attachment after this time" %>
    • - <%= f.submit(@attachment.new_record? ? "Create New Attachment" : "Save Changes", class: "btn primary") %> + <%= link_to "Cancel", :back, class: "btn-flat" %> + <%= f.submit(@attachment.new_record? ? "Create Attachment" : "Save Changes") %> +
    • +
    • +
    - <% end %> diff --git a/app/views/attachments/edit.html.erb b/app/views/attachments/edit.html.erb index dbf983b1d..2c8f6a5e9 100755 --- a/app/views/attachments/edit.html.erb +++ b/app/views/attachments/edit.html.erb @@ -6,6 +6,6 @@
    - <%= render partial: "form", locals: { path: path } %> + <%= render partial: "form", locals: { path: } %>
    diff --git a/app/views/attachments/new.html.erb b/app/views/attachments/new.html.erb index 20f08c893..f23aa2dac 100755 --- a/app/views/attachments/new.html.erb +++ b/app/views/attachments/new.html.erb @@ -7,6 +7,6 @@
    - <%= render partial: "form", locals: { path: path } %> + <%= render partial: "form", locals: { path: } %>
    diff --git a/app/views/autograders/_form.html.erb b/app/views/autograders/_form.html.erb index 00e78c112..eac621d47 100755 --- a/app/views/autograders/_form.html.erb +++ b/app/views/autograders/_form.html.erb @@ -1,47 +1,47 @@ <% content_for :javascripts do %> - + $(document).ready( function() { + $('#autograder_makefile:file').on('fileselect', function(event, numFiles, label) { + $('#makefile_name').val(label); + }); + $('#autograder_tar:file').on('fileselect', function(event, numFiles, label) { + $('#tar_name').val(label); + }); + }); + }); + <% end %> -<%= form_for [@course, @assessment, @autograder], - builder: FormBuilderWithDateTimeInput, - html: { multipart: true } do |f| %> +<%= form_for @autograder, url: course_assessment_autograder_path(@course, @assessment, @autograder), + builder: FormBuilderWithDateTimeInput, + html: { multipart: true } do |f| %> <%= f.error_messages %> + <%= f.text_field :autograde_image, display_name: "VM Image", + help_text: "VM image for autograding (e.g. rhel.img). #{link_to 'Click here', tango_status_course_jobs_path} to view the list of VM images and pools currently being used.".html_safe %> - <%= f.text_field :autograde_image, display_name: "VM Image" %> -

    - VM image for autograding (e.g., "rhel.img"). <%= link_to "Click here", tango_status_course_jobs_path %> to view the list of VM images and pools currently being used. -

    <%= f.text_field :autograde_timeout, display_name: "Timeout", - help_text: "Timeout for autograding jobs (secs)" %> - <%= f.check_box :release_score, display_name: "Release Scores?", - help_text: "Check to release autograded scores to students immediately after autograding (strongly recommended)." %> + help_text: "Timeout for autograding jobs (secs)" %> + <%= f.check_box :release_score, + display_name: "Release Scores?", + help_text: "Check to release autograded scores to students immediately after autograding (strongly recommended)." %> - <%= f.file_field :makefile, label_text: "Autograder Makefile", action: :upload %> - <%= f.file_field :tar, label_text: "Autograder Tar", action: :upload %> -

    - Both of the above files will be renamed upon upload. -

    + <%= f.file_field :makefile, label_text: "Autograder Makefile", action: :upload %> + <%= f.file_field :tar, label_text: "Autograder Tar", action: :upload %> +

    + Both of the above files will be renamed upon upload. +

    <%= f.submit "Save Settings" %> - - <%= link_to "Delete Autograder", [@course, @assessment, :autograder], method: :delete, class: "btn danger", - data: {confirm: "Are you sure you want to delete the Autograder for this assesssment?"} %> + + <%= link_to "Delete Autograder", course_assessment_autograder_path(@course, @assessment), + method: :delete, class: "btn danger", + data: { confirm: "Are you sure you want to delete the Autograder for this assesssment?" } %> <% end %> diff --git a/app/views/components/_dropdown_icon.html.erb b/app/views/components/_dropdown_icon.html.erb deleted file mode 100644 index 0612acb6e..000000000 --- a/app/views/components/_dropdown_icon.html.erb +++ /dev/null @@ -1,25 +0,0 @@ - - - expand_less - - -<% content_for :javascripts do %> - -<% end %> diff --git a/app/views/course_user_data/_fields.html.erb b/app/views/course_user_data/_fields.html.erb index b931337e8..d246a2cdd 100644 --- a/app/views/course_user_data/_fields.html.erb +++ b/app/views/course_user_data/_fields.html.erb @@ -1,23 +1,24 @@ -<%= f.fields_for :user, cud.user do |u| %> - <%# It doesn't make sense for these fields to be editable when editing an existing CUD %> - <%= u.email_field :email, disabled: edit, placeholder: "johndoe@example.com" %> +<% if @cud.instructor? %> + <%= f.fields_for :user, cud.user do |u| %> + <%# It doesn't make sense for these fields to be editable when editing an existing CUD %> + <%= u.email_field :email, disabled: edit, placeholder: "johndoe@example.com" %> - <%= u.text_field :first_name, disabled: edit, placeholder: "John" %> - <%= u.text_field :last_name, disabled: edit, placeholder: "Doe" %> + <%= u.text_field :first_name, disabled: edit, placeholder: "John" %> + <%= u.text_field :last_name, disabled: edit, placeholder: "Doe" %> + <% end %> <% end %> <%= f.text_field :nickname, help_text: "Anonymous nickname to display on the public scoreboards (max length: 32)", placeholder: "droh", maxlength: 32 %> -<%= f.text_field :course_number, help_text: "The course number", placeholder: "15213", disabled: !@cud.instructor? %> - -<%= f.text_field :lecture, help_text: "The lecture number", placeholder: "1", disabled: !@cud.instructor? %> +<% if @cud.instructor? %> + <%= f.text_field :course_number, help_text: "The course number", placeholder: "15213", disabled: !@cud.instructor? %> -<%= f.text_field :section, placeholder: "A", disabled: !@cud.instructor?, - help_text: "The section letter. A course assistant can see the gradebook and bulk-release grades for their assigned lecture and section." %> + <%= f.text_field :lecture, help_text: "The lecture number", placeholder: "1", disabled: !@cud.instructor? %> -<% if @cud.instructor? %> + <%= f.text_field :section, placeholder: "A", disabled: !@cud.instructor?, + help_text: "The section letter. A course assistant can see the gradebook and bulk-release grades for their assigned lecture and section." %>

    Course average tweak:

    diff --git a/app/views/course_user_data/edit.html.erb b/app/views/course_user_data/edit.html.erb index 6887a7c7e..f3a358a46 100755 --- a/app/views/course_user_data/edit.html.erb +++ b/app/views/course_user_data/edit.html.erb @@ -8,7 +8,7 @@ <%= form_for @editCUD, url: course_course_user_datum_path(@course, @editCUD), builder: FormBuilderWithDateTimeInput do |f| %>
    - <%= render partial: "fields", locals: { f: f, cud: @editCUD, edit: true } %> + <%= render partial: "fields", locals: { f:, cud: @editCUD, edit: true } %>
    diff --git a/app/views/course_user_data/new.html.erb b/app/views/course_user_data/new.html.erb index 47dddfb27..6fae4d39a 100755 --- a/app/views/course_user_data/new.html.erb +++ b/app/views/course_user_data/new.html.erb @@ -1,6 +1,6 @@ <% content_for :javascripts do %> <%= javascript_include_tag "course_user_data_edit" %> - + <%= javascript_include_tag 'collapsible' %> <% end %>
    @@ -27,9 +14,9 @@
    -
      -
    diff --git a/app/views/courses/moss.html.erb b/app/views/courses/moss.html.erb index bfb366386..80a422cea 100755 --- a/app/views/courses/moss.html.erb +++ b/app/views/courses/moss.html.erb @@ -2,184 +2,173 @@

    Run the Moss Cheat Checker

    -<% if @course.assessments.empty? %> -
      -
    • -

      You do not seem to have any assessments in this course to be able to run the Cheat Checker

      - <%= link_to "assignment Install Assessment".html_safe, install_assessment_course_assessments_path(@course), { title: "Install Assessment", class: "btn btn-large red darken-3" } %> -
    • -
    -<% else %> - <% content_for :javascripts do %> - - <% end %> +<% content_for :javascripts do %> + <%= javascript_include_tag "moss" %> + <%= javascript_include_tag "dropdown" %> +<% end %> + +Filter Courses (Keywords Separated by Space): + -

    Step 1:

    -

    Check the box for each assessment you want to send to Moss for cheat checking.

    +

    Step 1:

    +

    Check the box for each assessment you want to send to Moss for cheat checking.

    - <%= form_tag(run_moss_course_path(@course), multipart: true) do %> -
      -
    • -
      +<%= form_tag(run_moss_course_path(@course), multipart: true) do %> +
        + <% @courses.each do |course| %> +
      • +
        - <%= @course.full_name %> + <%= course.full_name %>
        - <%= render "components/dropdown_icon" %> + + + expand_less +
        -
          - <% for a in @course.assessments do %> +
            + <% if course.assessments.empty? %>
          • - -
            -
            + This course does not contain any assessments. +
          • + <% else %> + <% course.assessments.each do |a| %> +

          • - Check this if the submissions will need to be extracted. (The handin filename was: <%= a.handin_filename %>) -
            - Files to send to Moss: - <%= text_field_tag "files[#{a.id}]", "*hello.c *.c", required: true, html: { autocomplete: "on" } %> - This can be file names (foo.c) or patterns(*.c), space-separated -
            -
      -
    • +
      + +
      + Check this if the submissions will need to be extracted. (The handin filename was: <%= a.handin_filename %>) +
      + Files to send to Moss: + <%= text_field_tag "files[#{a.id}]", "*hello.c *.c", required: true, autocomplete: "on" %> + This can be file names (foo.c) or patterns(*.c), space-separated +
      +
      + + <% end %> <% end %>
  • -
- -
+ <% end %> + -

Step 2:

-

(Optional) Add flags that will be run with moss.

-
    -
  • -
    -
    - Moss Flags -
    - <%= render "components/dropdown_icon" %> - <% content_for :javascripts do %> - - <% end %> -
    - +
  • +
-
-
- Archive - <%= file_field_tag 'external_tar' %> -
-
- -
+

(Optional) Step 3:

+

Upload an archive containing additional files you'd like Moss to compare against.

+
+
+ Archive + <%= file_field_tag 'external_tar' %>
-
- -

Step 4:

-

Running Moss may take up to a minute, so be patient...

+
+ +
+
+ The archive must contain only regular files. Nested archives will not be extracted. -

<%= submit_tag "Run Moss", data: { disable_with: "Please wait..." }, class: "btn primary" %>

- <% end %> +

Step 4:

+

Running Moss may take up to a minute, so be patient...

+

<%= submit_tag "Run Moss", data: { disable_with: "Please wait..." }, class: "btn primary" %>

<% end %> diff --git a/app/views/courses/new.html.erb b/app/views/courses/new.html.erb index fc0b1e6c5..b4a3d83fa 100755 --- a/app/views/courses/new.html.erb +++ b/app/views/courses/new.html.erb @@ -13,7 +13,7 @@
- <%= email_field_tag :instructor_email, nil, class: "validate", placeholder: "", required: true %> + <%= email_field_tag :instructor_email, nil, class: "validate", placeholder: "", required: true, autocomplete: "off" %>

Email of an Instructor, they will set up the course from here!

diff --git a/app/views/courses/report_bug.html.erb b/app/views/courses/report_bug.html.erb index b604a47d7..6e28e9fbd 100755 --- a/app/views/courses/report_bug.html.erb +++ b/app/views/courses/report_bug.html.erb @@ -24,7 +24,7 @@ Note: if you encountered the following problems, please contact your course inst - + diff --git a/app/views/devise/confirmations/new.html.erb b/app/views/devise/confirmations/new.html.erb index 83ff029ef..68b38d9c6 100755 --- a/app/views/devise/confirmations/new.html.erb +++ b/app/views/devise/confirmations/new.html.erb @@ -1,7 +1,7 @@

Resend confirmation instructions

<%= form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f| %> - <%= render "devise/shared/error_messages", resource: resource %> + <%= render "devise/shared/error_messages", resource: %>
<%= f.label :email %>
<%= f.email_field :email, autofocus: true %>
diff --git a/app/views/devise/passwords/edit.html.erb b/app/views/devise/passwords/edit.html.erb index a6cbd29c2..07b4f77cc 100755 --- a/app/views/devise/passwords/edit.html.erb +++ b/app/views/devise/passwords/edit.html.erb @@ -1,7 +1,7 @@

Change your password

<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| %> - <%= render "devise/shared/error_messages", resource: resource %> + <%= render "devise/shared/error_messages", resource: %> <%= f.hidden_field :reset_password_token %>
diff --git a/app/views/devise/passwords/new.html.erb b/app/views/devise/passwords/new.html.erb index 8154a420c..b342ec532 100755 --- a/app/views/devise/passwords/new.html.erb +++ b/app/views/devise/passwords/new.html.erb @@ -1,7 +1,7 @@

Forgot your password?

<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| %> - <%= render "devise/shared/error_messages", resource: resource %> + <%= render "devise/shared/error_messages", resource: %>
<%= f.email_field :email, autofocus: true %> diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index ea37fa17e..f0b074ab7 100755 --- a/app/views/devise/registrations/edit.html.erb +++ b/app/views/devise/registrations/edit.html.erb @@ -1,7 +1,7 @@

Change password for <%= resource_name.to_s.humanize %>

<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %> - <%= render "devise/shared/error_messages", resource: resource %> + <%= render "devise/shared/error_messages", resource: %>
<%= f.email_field :email, readonly: true %> diff --git a/app/views/devise/unlocks/new.html.erb b/app/views/devise/unlocks/new.html.erb index 67cb741c1..7ef429a63 100755 --- a/app/views/devise/unlocks/new.html.erb +++ b/app/views/devise/unlocks/new.html.erb @@ -1,7 +1,7 @@

Resend unlock instructions

<%= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post }) do |f| %> - <%= render "devise/shared/error_messages", resource: resource %> + <%= render "devise/shared/error_messages", resource: %>
<%= f.label :email %>
<%= f.email_field :email, autofocus: true %>
diff --git a/app/views/doorkeeper/applications/index.html.erb b/app/views/doorkeeper/applications/index.html.erb index 4a3df8305..02fb48d7f 100644 --- a/app/views/doorkeeper/applications/index.html.erb +++ b/app/views/doorkeeper/applications/index.html.erb @@ -19,7 +19,7 @@
- + <% end %> diff --git a/app/views/doorkeeper/authorized_applications/index.html.erb b/app/views/doorkeeper/authorized_applications/index.html.erb index 91663871e..15cfcc37a 100644 --- a/app/views/doorkeeper/authorized_applications/index.html.erb +++ b/app/views/doorkeeper/authorized_applications/index.html.erb @@ -45,7 +45,7 @@ <% end %> - + <% end %> diff --git a/app/views/gradebooks/student.html.erb b/app/views/gradebooks/student.html.erb index 32e11f653..02dba7936 100755 --- a/app/views/gradebooks/student.html.erb +++ b/app/views/gradebooks/student.html.erb @@ -99,7 +99,7 @@ <% when AssessmentUserDatum::NORMAL %> <% case aud.submission_status when :submitted %> - <% if Time.now <= aud.assessment.grading_deadline || !aud.latest_submission.all_scores_released? %> + <% if !aud.latest_submission.all_scores_released? %> <% when :not_submitted %> - <% if Time.now <= aud.assessment.grading_deadline %> - - priority_high - - <% else %> - - <% end %> - + <% when :not_yet_submitted %> - <% if Time.now <= aud.assessment.grading_deadline %> - - priority_high - - <% else %> - - <% end %> + <% end %> <% end %> diff --git a/app/views/gradebooks/view.html.erb b/app/views/gradebooks/view.html.erb index fcb2435de..1f19f14be 100755 --- a/app/views/gradebooks/view.html.erb +++ b/app/views/gradebooks/view.html.erb @@ -43,8 +43,10 @@
- <% if @options[:show_actions] %> -
+ + <%# Always render #last_updated to ensure that search bar renders correctly even if actions are hidden %> +
+ <% if @options[:show_actions] %> Export / @@ -55,8 +57,8 @@ <%= time_ago_in_words(DateTime.parse(@matrix.last_updated)) %> old -
- <% end %> + <% end %> +
diff --git a/app/views/groups/new.html.erb b/app/views/groups/new.html.erb index a3deaee06..5e482c8b6 100644 --- a/app/views/groups/new.html.erb +++ b/app/views/groups/new.html.erb @@ -23,7 +23,7 @@
    <% @unfullGroups.each do |group| %> - <%= render "list_item", { group: group } %> + <%= render "list_item", { group: } %> <% end %>
<% end %> diff --git a/app/views/home/no_user.html.erb b/app/views/home/no_user.html.erb index cd4695bb3..676731d8f 100755 --- a/app/views/home/no_user.html.erb +++ b/app/views/home/no_user.html.erb @@ -67,7 +67,7 @@ Here are some resources to get you started.

We have a Rake task that you can run where your app is deployed to promote any user to the admin level. Just run rake - 'promote_to_admin[you@example.com]' to give a user administrative + 'admin:promote_user[you@example.com]' to give a user administrative privileges. Note that the user has to exist already.

diff --git a/app/views/jobs/getjob.html.erb b/app/views/jobs/getjob.html.erb index efd24d6f9..40afe2ba6 100755 --- a/app/views/jobs/getjob.html.erb +++ b/app/views/jobs/getjob.html.erb @@ -88,7 +88,7 @@ Output File
@@ -130,7 +130,7 @@ diff --git a/app/views/scoreboards/_form.html.erb b/app/views/scoreboards/_form.html.erb index f1c1c44c5..49900c005 100644 --- a/app/views/scoreboards/_form.html.erb +++ b/app/views/scoreboards/_form.html.erb @@ -1,4 +1,11 @@ <%= form_for [@course, @assessment, @scoreboard], builder: FormBuilderWithDateTimeInput do |f| %> + <% unless @errorMessage.nil? %> +

Error Rendering Scoreboard:

+
+
<%= @errorMessage %>
+
<%= @error %>
+
+ <% end %> <%= f.error_messages %>

Scoreboard Settings

diff --git a/app/views/scoreboards/show.html.erb b/app/views/scoreboards/show.html.erb index 9e0275085..d4ace9be3 100644 --- a/app/views/scoreboards/show.html.erb +++ b/app/views/scoreboards/show.html.erb @@ -61,12 +61,21 @@ <% if c["img"] %>

<% else %> - + <% if grade[:entry].is_a?(Array) && grade[:entry][i] != "-" %> + + <% else %> + <%= render 'error_icon' %> + <% end %> <% end %> <% end %> <% else %> - <% grade[:entry].each do |column| %> - + <%# this should be guaranteed to be an array, but for redundancy, check that entry is array %> + <% if grade[:entry].is_a?(Array) && grade[:entry][i] != "-" %> + <% grade[:entry].each do |column| %> + + <% end %>q + <% else %> + <%= render 'error_icon' %> <% end %> <% end %> diff --git a/app/views/submissions/_annotation.html.erb b/app/views/submissions/_annotation.html.erb index d5ffdc2a8..30cb160fd 100644 --- a/app/views/submissions/_annotation.html.erb +++ b/app/views/submissions/_annotation.html.erb @@ -1,4 +1,4 @@ -<% if @cud.instructor? or @cud.course_assistant? or @problemReleased %> +<% if @submission.grades_released?(@cud) %>
diff --git a/app/views/submissions/_annotation_form.html.erb b/app/views/submissions/_annotation_form.html.erb index c8afaa581..1ce573be8 100644 --- a/app/views/submissions/_annotation_form.html.erb +++ b/app/views/submissions/_annotation_form.html.erb @@ -3,11 +3,11 @@
- +

@@ -17,20 +17,18 @@

<% if global %> -
- +
+ Grading style: <%= @assessment.is_positive_grading ? "Positive grading" : "Negative grading" %> - Problems" %>" - >info_outline + Problems" %>">info_outline
<% else %> -
- +
+ Grading style: <%= @assessment.is_positive_grading ? "Positive grading" : "Negative grading" %> - Problems" %>" - >info_outline + Problems" %>">info_outline
diff --git a/app/views/submissions/_annotation_pane.html.erb b/app/views/submissions/_annotation_pane.html.erb index 61603a015..37ca0f608 100644 --- a/app/views/submissions/_annotation_pane.html.erb +++ b/app/views/submissions/_annotation_pane.html.erb @@ -22,7 +22,7 @@

<%= problem.capitalize %>
<% p_score = p_scores[@problemNameToId[problem]] %> - <% if @cud.instructor? or @cud.course_assistant? or @problemReleased %> + <% if @submission.grades_released?(@cud) %> <%= plus_fix(@problemScores[problem]) %> (<%= p_score&.score ? sprintf("%.2f", p_score.score.round(2)) : raw("–") %> / <%= @problemMaxScores[problem] ? sprintf("%.2f", @problemMaxScores[problem].round(2)) : raw("–") %>) @@ -41,7 +41,7 @@

- <% if @cud.instructor? or @cud.course_assistant? or @problemReleased %> + <% if @submission.grades_released?(@cud) %> <% global_annotations = all_annotations[:global_annotations] annotations_by_file = all_annotations[:annotations_by_file] %> <% if global_annotations.empty? && annotations_by_file.empty? %> @@ -52,7 +52,11 @@ <% global_annotations.each do |description, value, line, user, id, position, filename, shared, global| %>
- "> + "> <%= plus_fix(value) %>
@@ -66,11 +70,9 @@ data-problem="<%= problem %>" data-score="<%= value %>" data-comment="<%= description %>" - data-shared="<%= shared %>" - > + data-shared="<%= shared %>"> + title="Edit global annotation"> edit @@ -89,14 +91,18 @@ <% annotations.each do |description, value, line, user, id, position, filename, global| %>
<%= link_to( - url_for([:view, @course, @assessment, @submission, header_position: position.nil? ? 0 : position, line: line.nil? ? 1 : line+1]), - class: 'descript-link', - "data-header_position": position.nil? ? 0 : position, - "data-line": line.nil? ? 1 : line+1, - remote: true, - ) do %> + url_for([:view, @course, @assessment, @submission, { header_position: position.nil? ? 0 : position, line: line.nil? ? 1 : line + 1 }]), + class: 'descript-link', + "data-header_position": position.nil? ? 0 : position, + "data-line": line.nil? ? 1 : line + 1, + remote: true, + ) do %>
- "> + "> <% unless line.nil? %> Line <%= line + 1 %>: <% end %> diff --git a/app/views/submissions/_annotations_js.html.erb b/app/views/submissions/_annotations_js.html.erb index d2acefd71..a6dc55483 100644 --- a/app/views/submissions/_annotations_js.html.erb +++ b/app/views/submissions/_annotations_js.html.erb @@ -14,8 +14,8 @@ <% if @is_pdf %> newFile.pdf = true; - newFile.pdfUrl = "<%= url_for [:download, @course, @assessment, @submission, header_position: @header_position] %>"; - newFile.annotatedPdfUrl = "<%= url_for [:download, @course, @assessment, @submission, header_position: @header_position, annotated: true] %>"; + newFile.pdfUrl = "<%= url_for download_course_assessment_submission_path(@course, @assessment, @submission, header_position: @header_position) %>"; + newFile.annotatedPdfUrl = "<%= url_for download_course_assessment_submission_path(@course, @assessment, @submission, @header_position, annotated: true) %>"; newFile.previewMode = false; <% if @preview_mode %> newFile.previewMode = true; @@ -24,4 +24,3 @@ newFile.pdf = false; <% end %> - diff --git a/app/views/submissions/_code_symbol_tree.html.erb b/app/views/submissions/_code_symbol_tree.html.erb index 1c3599d56..1e1191b77 100644 --- a/app/views/submissions/_code_symbol_tree.html.erb +++ b/app/views/submissions/_code_symbol_tree.html.erb @@ -3,7 +3,7 @@
    <% @ctag_obj.each do |fdef| %> -
  • +
  • <%= image_tag "function_icon.svg" %> <%= fdef["name"] %>
  • @@ -11,4 +11,4 @@
<% end %> -
\ No newline at end of file +
diff --git a/app/views/submissions/_code_viewer.html.erb b/app/views/submissions/_code_viewer.html.erb index 6e0f17994..e693095bd 100644 --- a/app/views/submissions/_code_viewer.html.erb +++ b/app/views/submissions/_code_viewer.html.erb @@ -2,7 +2,7 @@ <% if @is_pdf %> <% else %> -
<% if( !(params.has_key?(:header_position) && params[:header_position].to_i < 0) ) %>
- <%= link_to [:download, @course, @assessment, @submission, header_position: @header_position ], - data: {toggle: "tooltip", placement:"top"}, - title: "Download Submission" do %> + <%= link_to download_course_assessment_submission_path(@course, @assessment, @submission, header_position: @header_position), + data: { toggle: "tooltip", placement: "top" }, + title: "Download Submission" do %> Download File <% end %>
<% end %> <% if @is_pdf %> -
- <%= link_to [:download, @course, @assessment, @submission, header_position: @header_position, annotated: true ], - data: {toggle: "tooltip", placement:"top"}, +
+ <%= link_to download_course_assessment_submission_path(@course, @assessment, @submission, header_position: @header_position, annotated: true), + data: { toggle: "tooltip", placement: "top" }, title: "Download Annotated Submission" do %> - Download Annotated - <% end %> -
+ Download Annotated + <% end %> +
<% else %> -
+
+ Copy Code +
<% end %>
<% success = true %> <% if @is_pdf %> -
-
-
+
+
+
<% else %>
<% @data.each_with_index do |(code, annotation), index| %> <% if code.valid_encoding? %> -
+
-
<%= index+1 %>
+
<%= index + 1 %>
- <% if @cud.instructor? or @cud.course_assistant? then %>
<% end %> + <% if @cud.instructor? or @cud.course_assistant? then %>
<% end %>
<% begin %>
"><%= code %>
@@ -64,7 +64,7 @@ <% break %> <% end %>
-
+
<% else %> <% success = false %> @@ -73,18 +73,23 @@
<% end %> <% if success == false %> -
Sorry, we could not parse your file because it contains non-UTF8 characters. Please <%= link_to [:download, @course, @assessment, @submission, header_position: @header_position ], - data: {toggle: "tooltip", placement:"top"}, +
+

+ Sorry, we could not parse your file because it contains non-UTF8 characters. Please + <%= link_to download_course_assessment_submission_path(@course, @assessment, @submission, header_position: @header_position), + data: { toggle: "tooltip", placement: "top" }, title: "Download Submission" do %> download the file - <% end %> to view the source.

+ <% end %> + to view the source. +

+
<% end %>
- <% if @is_pdf %> - + <% end %> diff --git a/app/views/submissions/_file_tree.html.erb b/app/views/submissions/_file_tree.html.erb index 2fbdcbae4..d81d1b9c7 100644 --- a/app/views/submissions/_file_tree.html.erb +++ b/app/views/submissions/_file_tree.html.erb @@ -6,17 +6,20 @@ def renderFileTree(file, isRoot: false) return "" end - if(!file[:directory]) %> + if(!file[:directory]) +%> <%= link_to( - url_for([:view, @course, @assessment, @submission, header_position: file[:header_position]]), - class: 'file valign-wrapper noselect' + ((file[:header_position] == params[:header_position]) ? ' active' : ""), - "data-header_position": file[:header_position], - remote: true) do %> + view_course_assessment_submission_path(@course, @assessment, @submission, header_position: file[:header_position]), + class: "file valign-wrapper noselect#{file[:header_position] == params[:header_position] ? ' active' : ''}", + "data-header_position": file[:header_position], + remote: true + ) do %> insert_drive_file <%= file[:pathname].split("/").last %> <% end %> <% - else %> + else +%>
" onclick="switchFolderState($(this))"> folder @@ -39,7 +42,7 @@ end
<% @files.sort { |a, b| a[:header_position] <=> b[:header_position] }.each do |file| %> <% - if(!file[:mac_bs_file]) + if !file[:mac_bs_file] renderFileTree(file, isRoot: true) end %> diff --git a/app/views/submissions/_golden-layout.html.erb b/app/views/submissions/_golden-layout.html.erb index 8d5459f24..fdb60579d 100644 --- a/app/views/submissions/_golden-layout.html.erb +++ b/app/views/submissions/_golden-layout.html.erb @@ -1,26 +1,26 @@ + <% content_for :javascripts do %> <%= external_javascript_include_tag "lodash" %> <%= javascript_include_tag "sorttable" %> + <%= external_javascript_include_tag "jquery-ui" %> <%= external_javascript_include_tag "jquery.dataTables" %> <%= external_javascript_include_tag "datatables-buttons" %> + <%= javascript_include_tag "autolab_component" %> + <%= javascript_include_tag "annotations_helpers" %> + <%= javascript_include_tag "annotations_popup" %> <%= javascript_include_tag "manage_submissions" %> <%= javascript_include_tag "datatables-rows" %> <% end %> @@ -192,3 +216,11 @@
+ + diff --git a/app/views/submissions/viewPDF.html.erb b/app/views/submissions/viewPDF.html.erb index 34d9aad4d..b551e7513 100644 --- a/app/views/submissions/viewPDF.html.erb +++ b/app/views/submissions/viewPDF.html.erb @@ -114,6 +114,7 @@ <% if not @preview_mode then %> + <%= javascript_include_tag "annotations_helpers.js" %> <%= javascript_include_tag "annotations.js" %> <% end %> diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index c7e834b7b..7bed006f0 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -38,6 +38,8 @@ Rails.application.config.assets.precompile += %w( users.css.scss ) Rails.application.config.assets.precompile += %w( *.js ) +Rails.application.config.assets.precompile += %w( annotations_helpers.js ) +Rails.application.config.assets.precompile += %w( annotations_popup.js ) Rails.application.config.assets.precompile += %w( annotations.js ) Rails.application.config.assets.precompile += %w( chroma.min.js ) Rails.application.config.assets.precompile += %w( gradesheet.js.erb ) diff --git a/config/routes.rb b/config/routes.rb index f85de41a7..98401a654 100755 --- a/config/routes.rb +++ b/config/routes.rb @@ -144,6 +144,7 @@ get "destroyConfirm" get "download" get "view" + get "tweak_total" end collection do From f06e55cda853d21397e00fdf7532fa40993560db Mon Sep 17 00:00:00 2001 From: lykimchee Date: Tue, 19 Nov 2024 14:27:46 -0500 Subject: [PATCH 06/14] [Manage Submissions] Score popup modal styling + fixes (#1952) * Style fixes * Fix formatting and styling * Fix undefined error showing up for all problems and scores * Strengthened checks and nil case * Changed styling and removed external stylesheet * Increased font size and used variables --------- Co-authored-by: Kester --- app/assets/javascripts/manage_submissions.js | 49 ++++++++++++---- app/assets/stylesheets/_variables.scss | 1 + .../stylesheets/manage_submissions.css.scss | 57 +++++++++++++++++++ app/assets/stylesheets/style.css.scss | 12 +++- app/controllers/submissions_controller.rb | 5 +- app/views/submissions/index.html.erb | 37 ++++++------ 6 files changed, 126 insertions(+), 35 deletions(-) create mode 100644 app/assets/stylesheets/manage_submissions.css.scss diff --git a/app/assets/javascripts/manage_submissions.js b/app/assets/javascripts/manage_submissions.js index 37d6bb69c..ac88ced5e 100644 --- a/app/assets/javascripts/manage_submissions.js +++ b/app/assets/javascripts/manage_submissions.js @@ -84,13 +84,19 @@ $(document).ready(function () { // Fetch data and render it in the modal get_score_details(course_user_datum_id).then((data) => { + const sorting_icons = + ` + + `; + 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 `
`; }).join(''); @@ -154,7 +160,10 @@ $(document).ready(function () { ${submission.problems. map((problem) => - `` + data.scores[submission.id]?.[problem.id] ? + `` + : + `` ).join('')} `; - }).join(''); - tweaks = []; + 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 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}); + 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); + }); + } + } - let tweak_value = data?.tweaks[submission.id]?.value ?? "None"; - if (tweak_value != "None" && tweak_value > 0) { - tweak_value = `+${tweak_value}`; - } + $(document).ready(function () { + $('.modal').modal(); - // 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'); - - const view_button = submission.filename ? - `
- - zoom_in - -

View Source

-
` - : "None"; - - const download_button = - /text/.test(submission.detected_mime_type) ? - `
- - file_download - -

Download

-
` : + $('.score-details').on('click', function () { + // Get the email + const course_user_datum_id = $(this).data('cuid'); + const email = $(this).data('email'); + + // 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 `
`; + }).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}); + + let tweak_value = data?.tweaks[submission.id]?.value ?? "None"; + if (tweak_value != "None" && tweak_value > 0) { + tweak_value = `+${tweak_value}`; + } + + // 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'); + + const view_button = submission.filename ? ``; - return ` - - - - - ${submission.problems. - map((problem) => - data.scores[submission.id]?.[problem.id] ? - `` - : - `` - ).join('')} - - - - `; - }).join(''); - - const submissions_table = - `

Click on non-autograded problem scores to edit or leave a comment.

-
Title: <%= text_field_tag :title %><%= text_field_tag :title, autocomplete: "off" %>
Summary: <%= link_to application.name, oauth_application_path(application) %> <%= application.redirect_uri %> <%= link_to t('doorkeeper.applications.buttons.edit'), edit_oauth_application_path(application), class: 'btn btn-link' %><%= render 'delete_form', application: application %><%= render 'delete_form', application: %>
<% if application.scopes.include? "admin_all" %>done<% end %><%= most_recent_grant.created_at %><%= render 'delete_form', application: application %><%= render 'delete_form', application: %>
- <%= @job[:rjob]["outputFile"].sub(%r{/afs/cs\.cmu\.edu/academic/autolab/autolab2}, "[autolab]") %> + <%= @job[:rjob]["outputFile"].sub(%r{courselabs/(.*?)-}, "courselabs/[redacted]-") %>
- <%= item["localFile"].sub(%r{/afs/cs\.cmu\.edu/academic/autolab/autolab2/courses}, "[autolab courses]") %> + <%= item["localFile"].sub(%r{courselabs/(.*?)-}, "courselabs/[redacted]-") %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index c64fa11ec..75b886910 100755 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -60,11 +60,9 @@
- <% if @cud && @cud.dropped? %> -

- You seem to have dropped this course. Please email your instructor if this is incorrect. -

- <% end %> + <% if @cud&.dropped? + flash[:error] = "You seem to have dropped this course. Please email your instructor if this is incorrect." + end %>
diff --git a/app/views/problems/edit.html.erb b/app/views/problems/edit.html.erb index d6ad0cfe1..b698ecb41 100755 --- a/app/views/problems/edit.html.erb +++ b/app/views/problems/edit.html.erb @@ -10,5 +10,5 @@ <%= form_for @problem, url: course_assessment_problem_path(@course, @assessment, @problem), method: :patch, builder: FormBuilderWithDateTimeInput do |f| %> - <%= render partial: "fields", locals: { f: f } %> + <%= render partial: "fields", locals: { f: } %> <% end %> diff --git a/app/views/problems/new.html.erb b/app/views/problems/new.html.erb index 78d67de10..2cd68e542 100755 --- a/app/views/problems/new.html.erb +++ b/app/views/problems/new.html.erb @@ -10,5 +10,5 @@ <%= form_for :problem, url: course_assessment_problems_path(@course, @assessment), method: :post, html: { class: 'edit_problem' }, builder: FormBuilderWithDateTimeInput do |f| %> - <%= render partial: "fields", locals: { f: f } %> + <%= render partial: "fields", locals: { f: } %> <% end %> diff --git a/app/views/schedulers/new.html.erb b/app/views/schedulers/new.html.erb index 35e0cbaf7..7dd729c60 100755 --- a/app/views/schedulers/new.html.erb +++ b/app/views/schedulers/new.html.erb @@ -6,11 +6,11 @@ <%= form_for :scheduler, url: course_schedulers_path(@course), builder: FormBuilderWithDateTimeInput do |f| %> <%= f.error_messages %> <%= f.text_field :action, help_text: "Path to scheduler file relative to Autolab root", - placeholder: "(scheduler action)" %> + placeholder: "(scheduler action)" %> <%= f.datetime_select :next, help_text: "Time of next run" %> <%= f.datetime_select :until, help_text: "Don't run after this time" %> <%= f.number_field :interval, min: 1, help_text: "Minimum interval between each run (in seconds)", - value: 600 %> + value: 600 %> <%= f.check_box :disabled %>
<%= link_to 'Back', course_schedulers_path(@course), { class: "btn" } %> diff --git a/app/views/scoreboards/_error_icon.html.erb b/app/views/scoreboards/_error_icon.html.erb new file mode 100644 index 000000000..6d9a3de43 --- /dev/null +++ b/app/views/scoreboards/_error_icon.html.erb @@ -0,0 +1,7 @@ +
+
+ + error_outline + +
+
<%= grade[:entry][i] %><%= grade[:entry][i] %><%= column %><%= column %>
- ${problem.name} -
- ${max_score} ${autograded} + return `
+
+ ${problem.name} + ${sorting_icons} +
+ ${max_score}
${data.scores[submission.id]?.[problem.id]?.['score'] ?? "-"}${data.scores[submission.id][problem.id]?.['score']}- ${submission.late_penalty} @@ -175,11 +184,31 @@ $(document).ready(function () { - - - + + + ${problem_headers} - + diff --git a/app/assets/stylesheets/_variables.scss b/app/assets/stylesheets/_variables.scss index ca57770ee..cc6e5e278 100644 --- a/app/assets/stylesheets/_variables.scss +++ b/app/assets/stylesheets/_variables.scss @@ -10,3 +10,4 @@ $autolab-blue-text: #0869af; $autolab-green: #3a862d; $autolab-white: #fff; $autolab-medium-gray: #6f6f6f; +$autolab-submissions-background: #F2F2F2; diff --git a/app/assets/stylesheets/manage_submissions.css.scss b/app/assets/stylesheets/manage_submissions.css.scss new file mode 100644 index 000000000..4a3b304e2 --- /dev/null +++ b/app/assets/stylesheets/manage_submissions.css.scss @@ -0,0 +1,57 @@ +/* Score Popup */ +@import 'variables'; + +#modal-close { + font-size: 2rem; + position: absolute; + top: -35%; + right: 0; + padding: 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; +} + +/* 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 9a81bb77d..90b4e8481 100644 --- a/app/assets/stylesheets/style.css.scss +++ b/app/assets/stylesheets/style.css.scss @@ -785,6 +785,7 @@ table.prettyBorder { .submissions-th { padding: 25px 0 25px 0; + font-size: 0.9rem; div { display: flex; align-items: center; @@ -792,7 +793,6 @@ table.prettyBorder { p { margin: 0; float: left; - padding-right: 3px; } } @@ -825,7 +825,7 @@ table.prettyBorder { .submissions-td { border: none; border-bottom: 1px solid #ddd; - padding: 5px 0 5px 0; + padding: 5px 0 5px 0.6rem; } .submissions-cbox-label { @@ -1586,6 +1586,10 @@ table.sub td, th { align-items: center; } +.submissions-center-icons .btn i{ + margin: 0; +} + .submissions-center-icons p { margin: 0 0 0 10px; } @@ -1594,6 +1598,10 @@ table.sub td, th { margin-left: 3px; } +i.left { + margin-right: 10px; +} + .submissions-score-align { display: flex; align-items: center; diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index 9592630c2..3a124099e 100755 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -46,12 +46,11 @@ def score_details submission.global_annotations.empty? ? nil : submission.global_annotations.sum(:value) end - autograded = @assessment.has_autograder? + submissions = submissions.as_json(seen_by: @cud) render json: { submissions: submission_info, scores: submission_id_to_score_data, - tweaks:, - autograded: }, status: :ok + tweaks: tweaks }, status: :ok rescue StandardError => e render json: { error: e.message }, status: :not_found nil diff --git a/app/views/submissions/index.html.erb b/app/views/submissions/index.html.erb index 3333bfdae..dc8306455 100755 --- a/app/views/submissions/index.html.erb +++ b/app/views/submissions/index.html.erb @@ -2,7 +2,6 @@ <% content_for :stylesheets do %> <%= stylesheet_link_tag "datatable.adapter" %> - <%= stylesheet_link_tag "datatables-rows" %> <%= stylesheet_link_tag "manage_submissions" %> <%= stylesheet_link_tag "annotations" %> <%= external_stylesheet_link_tag "jquery-ui" %> @@ -36,7 +35,7 @@ var currentHeaderPos = null; <%= javascript_include_tag "annotations_helpers" %> <%= javascript_include_tag "annotations_popup" %> <%= javascript_include_tag "manage_submissions" %> - <%= javascript_include_tag "datatables-rows" %> + <%= external_javascript_include_tag "datatables-rows" %> <% end %>

Manage Submissions

@@ -169,10 +168,10 @@ var currentHeaderPos = null; @@ -206,12 +205,10 @@ var currentHeaderPos = null;
Version No.Submission DateFinal Score +
+ Version No. + ${sorting_icons} +
+
+
+ Submission Date + ${sorting_icons} +
+
+
+ Final Score + ${sorting_icons} +
+
Late Penalty +
+ Late Penalty + ${sorting_icons} +
+
Tweak Actions
<% if submission.filename then %>
- <%= link_to "zoom_in".html_safe, - [:view, @course, @assessment, submission], - { title: "View the file for this submission", - class: "btn small" } %> + <%= link_to "zoom_in".html_safe, + [:view, @course, @assessment, submission], + {:title=>"View the file for this submission", + :class=>"btn small"} %>

View File

<% else %> @@ -183,20 +182,20 @@ var currentHeaderPos = null;
<% if @autograded and submission.version > 0 then %>
- <%= button_to regrade_course_assessment_path(@course, @assessment, submission_id: submission.id), - method: :post, - title: "Rerun the autograder on this submission", - class: "btn small" do %> - autorenew + <%= button_to [:regrade, @course, @assessment, submission_id: submission.id], + :method => :post, + :title=>"Rerun the autograder on this submission", + :class=>"btn small" do %> + autorenew <% end %>

Regrade

<% end %>
- <%= link_to "delete_outline".html_safe, - destroyConfirm_course_assessment_submission_path(@course, @assessment, submission), - { title: "Destroy this submission forever", - class: "btn small" } %> + <%= link_to "delete_outline".html_safe, + destroyConfirm_course_assessment_submission_path(@course, @assessment, submission), + {:title=>"Destroy this submission forever", + :class=>"btn small"} %>

Delete

-
- ${problem.name} - ${sorting_icons} -
- ${max_score} -
+ ${problem.name} +
+ ${max_score} ${autograded} +
- ${submission.version} - - ${human_readable_created_at} - - ${submission.total} - ${data.scores[submission.id][problem.id]?.['score']}- - ${submission.late_penalty} - -
-
-
- ${view_button} - ${download_button} -
- - - - + + + + ${submission.problems. + map((problem) => + `` + ).join('')} + + - ${problem_headers} - - - - - - - ${submissions_body} - -
-
- Version No. - ${sorting_icons} -
-
-
- Submission Date - ${sorting_icons} +

View Source

+
` + : "None"; + + const download_button = + /text/.test(submission.detected_mime_type) ? + `
+ + file_download + +

Download

+
` : + `
+ + file_download + +

Download

+
`; + return ` +
+ ${submission.version} + + ${human_readable_created_at} + + ${submission.total} + ${data.scores[submission.id]?.[problem.id]?.['score'] ?? "-"} + ${submission.late_penalty} + +
- -
-
- Final Score - ${sorting_icons} -
-
-
- Late Penalty - ${sorting_icons} -
-
TweakActions
- `; - - $('#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(` -
-
-
- ${err} + + + ${view_button} + ${download_button} + + `; + }).join(''); + + const submissions_table = + `

Click on non-autograded problem scores to edit or leave a comment.

+ + + + + + + ${problem_headers} + + + + + + + ${submissions_body} + +
Version No.Submission DateFinal ScoreLate PenaltyTweakActions
+ `; + + $('#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(` +
+
+
+ ${err} +
-
-
`); +
`); + }); }); - }); - // USE LATER FOR GROUPING ROWS (POSSIBLY): - - // $.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"); - // } - // } - // ); - - var table = $('#submissions').DataTable({ - "dom": 'fBrt', // show buttons, search, table - buttons: [ - { text: 'cachedRegrade Selected', className: 'btn submissions-selected disabled' }, - { text: 'delete_outlineDelete Selected', className: 'btn submissions-selected disabled' }, - { text: 'downloadDownload Selected', className: 'btn submissions-selected disabled' }, - { text: 'doneExcuse Selected', className: 'btn submissions-selected disabled' } - ] - }); + var selectedStudentCids = []; + var selectedSubmissions = []; + + var table = $('#submissions').DataTable({ + 'dom': 'f<"selected-buttons">rt', // show buttons, search, table + 'paging': false, + 'createdRow': completeRow, + }); - var selectedSubmissions = []; - - // USE LATER FOR REGRADE SELECTED (POSSIBLY): - - // var initialBatchUrl = $("#batch-regrade").prop("href"); - - // function updateBatchRegradeButton() { - // if (selectedSubmissions.length == 0) { - // $("#batch-regrade").fadeOut(120); - // } else { - // $("#batch-regrade").fadeIn(120); - // } - // var urlParam = $.param({ - // "submission_ids": selectedSubmissions - // }); - // 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"); + // 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 { - // in the list - $("#cbox-" + submissionId).prop('checked', false); - $("#row-" + submissionId).removeClass("selected"); - selectedSubmissions = _.without(selectedSubmissions, submissionId); + $('#no-data-message').hide(); // Hide custom message when there is data } - // updateBatchRegradeButton(); - } - $("#submissions").on("click", ".exclude-click i", function (e) { - e.stopPropagation(); - return; - }); + function completeRow(row, data, index) { + var submission = additional_data[index]; + $(row).attr('data-submission-id', submission['submission-id']); + } + + // Listen for select-all checkbox click + $('#cbox-select-all').on('click', async function(e) { + var selectAll = $(this).is(':checked'); + await toggleAllRows(selectAll); + }); + + // Function to toggle all checkboxes + function toggleAllRows(selectAll) { + $('#submissions tbody .cbox').each(function() { + 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 + } + + + // SELECTED BUTTONS + + // 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(); + } + + // 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", ".cbox", function (e) { - var submissionId = parseInt(e.currentTarget.id.replace("cbox-", ""), 10); - toggleRow(submissionId); - e.stopPropagation(); + $('#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').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 cc6e5e278..4e1c479f8 100644 --- a/app/assets/stylesheets/_variables.scss +++ b/app/assets/stylesheets/_variables.scss @@ -10,4 +10,12 @@ $autolab-blue-text: #0869af; $autolab-green: #3a862d; $autolab-white: #fff; $autolab-medium-gray: #6f6f6f; -$autolab-submissions-background: #F2F2F2; +$autolab-submissions-background: #EBEBEA; +$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; diff --git a/app/assets/stylesheets/annotations.scss b/app/assets/stylesheets/annotations.scss index 82f7cad3b..3c17a25bc 100755 --- a/app/assets/stylesheets/annotations.scss +++ b/app/assets/stylesheets/annotations.scss @@ -4,7 +4,6 @@ .page-wrapper { height: 100vh; - overflow: hidden; } .row { 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/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 83e667367..64413a03d 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 deleted file mode 100644 index 4a3b304e2..000000000 --- a/app/assets/stylesheets/manage_submissions.css.scss +++ /dev/null @@ -1,57 +0,0 @@ -/* Score Popup */ -@import 'variables'; - -#modal-close { - font-size: 2rem; - position: absolute; - top: -35%; - right: 0; - padding: 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; -} - -/* 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 90b4e8481..e817f4ad2 100644 --- a/app/assets/stylesheets/style.css.scss +++ b/app/assets/stylesheets/style.css.scss @@ -752,7 +752,7 @@ table.prettyBorder { tr { background-color: #fff; &:hover { - background-color: #abcdef; + background-color: $autolab-sky-blue; } } @@ -772,8 +772,8 @@ table.prettyBorder { top: 0; } th { - background-color: #ebebeb; - color: #000000; + background-color: $autolab-light-grey; + color: $autolab-black; cursor: pointer; font-family: Source Sans Pro, sans-serif; font-size: 0.8em; @@ -793,6 +793,7 @@ table.prettyBorder { p { margin: 0; float: left; + padding-right: 3px; } } @@ -823,9 +824,8 @@ table.prettyBorder { } .submissions-td { - border: none; - border-bottom: 1px solid #ddd; - padding: 5px 0 5px 0.6rem; + border-style: none; + padding: 5px 0 5px 0; } .submissions-cbox-label { @@ -1539,7 +1539,6 @@ table.sub td, th { /* Manage Submissions */ - .btn.submissions-main { margin: 0; padding: 5px 10px 5px 5px; @@ -1550,10 +1549,19 @@ table.sub td, th { align-items: center; } +.btn.submissions-main i.left { + margin-right: 6px; +} + .btn.submissions-selected { margin: 0; + margin-right: 10px; padding: 0px 10px 0px 5px; - font-family: Source Sans Pro, sans-serif; + display: flex; +} + +.btn.submissions-selected i { + margin: 0 6px 0 4px; } .buttons-row { @@ -1577,6 +1585,25 @@ table.sub td, th { 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; } @@ -1586,6 +1613,53 @@ table.sub td, th { 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; } @@ -1594,12 +1668,22 @@ table.sub td, th { 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; } -i.left { - margin-right: 10px; +.submissions-name { + display: flex; + flex-direction: row; + align-items: center; } .submissions-score-align { @@ -1617,5 +1701,5 @@ i.left { .submissions-score-icon { margin-left: 5px; margin-top: 5px; - color: #A1A0A3; + color: $autolab-grey; } diff --git a/app/controllers/assessment/grading.rb b/app/controllers/assessment/grading.rb index 6829d0a26..74c06ddb9 100755 --- a/app/controllers/assessment/grading.rb +++ b/app/controllers/assessment/grading.rb @@ -1,535 +1,543 @@ -require "csv" -require "utilities" - -module AssessmentGrading - # Export all scores for an assessment for all students as CSV - def bulkExport - # generate CSV - csv = render_to_string layout: false - - # send CSV file - timestamp = Time.now.strftime "%Y%m%d%H%M" - file_name = "#{@course.name}_#{@assessment.name}_#{timestamp}.csv" - send_data csv, filename: file_name - end - - # Allows the user to upload multiple scores or comments from a CSV file - def bulkGrade - return unless request.post? - - # part 1: submitting a CSV for processing and returning errors in CSV - if params[:upload] - # get data type - @data_type = params[:upload][:data_type].to_sym - unless @data_type == :scores || @data_type == :feedback - flash[:error] = "bulkGrade: invalid data_type received from client" - redirect_to(action: :bulkGrade) && return - end - - # get CSV - csv_file = params[:upload][:file] - if csv_file - @csv = csv_file.read - else - flash[:error] = "You need to choose a CSV file to upload." - redirect_to(action: :bulkGrade) && return - end - - # process CSV - success, entries = parse_csv @csv, @data_type - if success - @entries = entries - @valid_entries = valid_entries? entries - else - redirect_to(action: :bulkGrade) && return - end - end - end - - # part 2: confirming a CSV upload and saving data - def bulkGrade_complete - redirect_to(action: :bulkGrade) && return unless request.post? - - # retrieve entries CSV from hidden field in form - csv = params[:confirm][:bulkGrade_csv] - data_type = params[:confirm][:bulkGrade_data_type].to_sym - unless csv && data_type - flash[:error] = "Please try again." - redirect_to(action: :bulkGrade) && return - end - - success, @entries = parse_csv csv, data_type - if !success - flash[:error] = "bulkGrade_complete: invalid csv returned from client" - redirect_to(action: :bulkGrade) && return - elsif !valid_entries?(@entries) - flash[:error] = "bulkGrade_complete: invalid entries returned from client" - redirect_to(action: :bulkGrade) && return - end - - # save data - unless save_entries @entries, data_type - flash[:error] = "Failed to Save Entries" - redirect_to(action: :bulkGrade) && return - end - end - -private - - def valid_entries?(entries) - entries.reduce true do |acc, entry| - acc && valid_entry?(entry) - end - end - - def valid_entry?(entry) - entry.values.reduce true do |acc, v| - acc && (case v - when Hash - !v.include?(:error) && valid_entry?(v) - else - true - end) - end - end - - def save_entries(entries, data_type) - asmt = @assessment - - begin - User.transaction do - entries.each do |entry| - user = CourseUserDatum.joins(:user) - .find_by(users: { email: entry[:email] }, course: asmt.course) - - aud = AssessmentUserDatum.get asmt.id, user.id - if entry[:grade_type] - aud.grade_type = AssessmentUserDatum::GRADE_TYPE_MAP[entry[:grade_type]] - aud.save! - end - - unless sub = aud.latest_submission - sub = asmt.submissions.create!( - course_user_datum_id: user.id, - assessment_id: asmt.id, - submitted_by_id: @cud.id, - created_at: [Time.current, asmt.due_at].min - ) - end - - entry[:data].each do |problem_name, datum| - next unless datum - - problem = asmt.problems.find_by_name problem_name - - score = sub.scores.find_by_problem_id problem.id - unless score - score = sub.scores.new( - grader_id: @cud.id, - problem_id: problem.id - ) - end - - case data_type - when :scores - score.score = datum - when :feedback - score.feedback = datum.gsub("\\n", "\n").gsub("\\t", "\t") - end - - updateScore user.id, score - end - end # entries.each - end # User.transaction - - true - rescue ActiveRecord::ActiveRecordError => e - flash[:error] = "An error occurred: #{e}" - - false - end - end - - def parse_csv(csv, data_type) - # inputs for parse_csv_row - problems = @assessment.problems - emails = Set.new(CourseUserDatum.joins(:user).where(course: @assessment.course).map &:email) - - # process CSV - entries = [] - begin - CSV.parse(csv, skip_blanks: true) do |row| - entries << parse_csv_row(row, data_type, problems, emails) - end - rescue CSV::MalformedCSVError => e - flash[:error] = "Failed to parse CSV -- make sure the grades " \ - "are formatted correctly:
#{e}
" - flash[:html_safe] = true - return false, [] - end - - [true, entries] - end - - def parse_csv_row(row, kind, problems, emails) - row = row.dup - - email = row.shift.to_s - data = row.shift problems.count - grade_type = row.shift.to_s - - # to be returned - processed = {} - processed[:extra_cells] = row if row.length > 0 # currently unused - - # Checking that emails are valid - processed[:email] = if email.blank? - { error: nil } - elsif emails.include? email - email - else - { error: email } - end - - # data - data.map! do |datum| - if datum.blank? - nil - else - case kind - when :scores - Float(datum) rescue({ error: datum }) - when :feedback - datum - end - end - end - - # pad data with nil until there are problems.count elements - data.fill nil, data.length, problems.count - data.length - - processed[:data] = {} - problems.each_with_index { |problem, i| processed[:data][problem.name] = data[i] } - - # grade type - processed[:grade_type] = if grade_type.blank? - nil - elsif AssessmentUserDatum::GRADE_TYPE_MAP.key? grade_type.to_sym - grade_type.to_sym - else - { error: grade_type } - end - - processed - end - -public - - def quickSetScore - return unless request.post? - return unless params[:submission_id] - return unless params[:problem_id] - - # get submission and problem IDs - sub_id = params[:submission_id].to_i - prob_id = params[:problem_id].to_i - - # find existing score for this problem, if there's one - # otherwise, create it - score = Score.find_or_initialize_by_submission_id_and_problem_id(sub_id, prob_id) - - score.grader_id = @cud.id - score.score = params[:score].to_f - - updateScore(score.submission.course_user_datum_id, score) - - render plain: score.score - - # see http://stackoverflow.com/questions/6163125/duplicate-records-created-by-find-or-create-by - # and http://barelyenough.org/blog/2007/11/activerecord-race-conditions/ - # and http://stackoverflow.com/questions/5917355/find-or-create-race-conditions - rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordInvalid => error - @retries_left ||= 2 - retry unless (@retries_left -= 1) < 0 - raise error - end - - def quickSetScoreDetails - return unless request.post? - return unless params[:submission_id] - return unless params[:problem_id] - # get submission and problem IDs - sub_id = params[:submission_id].to_i - prob_id = params[:problem_id].to_i - - - # find existing score for this problem, if there's one - # otherwise, create it - score = Score.find_or_initialize_by_submission_id_and_problem_id(sub_id, prob_id) - - score.grader_id = @cud.id - score.feedback = params[:feedback] - score.released = params[:released] - - updateScore(score.submission.course_user_datum_id, score) - - render plain: score.id - - # see http://stackoverflow.com/questions/6163125/duplicate-records-created-by-find-or-create-by - # and http://barelyenough.org/blog/2007/11/activerecord-race-conditions/ - # and http://stackoverflow.com/questions/5917355/find-or-create-race-conditions -rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordInvalid => error - @retries_left ||= 2 - retry unless (@retries_left -= 1) < 0 - raise error -end - - def submission_popover - submission = Submission.find_by(id: params[:submission_id].to_i) - if submission - render partial: "popover", locals: { s: submission } - else - render plain: "Submission not found", status: :not_found - end - end - - def score_grader_info - score = Score.find(params[:score_id]) - grader = (if score then score.grader else nil end) - grader_info = "" - if grader - grader_info = grader.full_name_with_email - end - - feedback = score.feedback - response = { "grader" => grader_info, "feedback" => feedback, "score" => score.score } - render json: response - end - - def viewGradesheet - load_gradesheet_data - end - - def quickGetTotal - return unless params[:submission_id] - - # get submission and problem IDs - sub_id = params[:submission_id].to_i - - render plain: Submission.find(sub_id).final_score(@cud) - end - - def statistics - load_course_config - latest_submissions = @assessment.submissions.latest_for_statistics.includes(:scores, :course_user_datum) - #latest_submissions = @assessment.submissions.latest.includes(:scores, :course_user_datum) - - # Each value other than for :all is of the form - # [[, {:mean, :median, :max, :min, :stddev}]...] - # for each group. :all has just the hash. - @statistics = {} - @scores = {} - # Rather than special case this, we just index into the result. - by_assessment = latest_submissions.group_by { |s| s.assessment.name } - assessment_stats = stats_for_grouping(by_assessment) - all_grouping = assessment_stats[assessment_stats.keys[0]] - - if all_grouping.nil? - @statistics[:all] = [] - @scores[:all] = [] - else - @statistics[:all] = all_grouping[:data] - @scores[:all] = all_grouping[1] - end - - by_course_number = latest_submissions.group_by { |s| s.course_user_datum.course_number } - @statistics[:course_number] = stats_for_grouping(by_course_number) - @scores[:course_number] = scores_for_grouping(by_course_number) - - by_lecture = latest_submissions.group_by { |s| s.course_user_datum.lecture } - @statistics[:lecture] = stats_for_grouping(by_lecture) - @scores[:lecture] = scores_for_grouping(by_lecture) - - by_section = latest_submissions.group_by { |s| s.course_user_datum.section } - @statistics[:section] = stats_for_grouping(by_section) - @scores[:section] = scores_for_grouping(by_section) - - by_school = latest_submissions.group_by { |s| s.course_user_datum.school } - @statistics[:school] = stats_for_grouping(by_school) - @scores[:school] = scores_for_grouping(by_school) - - by_major = latest_submissions.group_by { |s| s.course_user_datum.major } - @statistics[:major] = stats_for_grouping(by_major) - @scores[:major] = scores_for_grouping(by_major) - - by_year = latest_submissions.group_by { |s| s.course_user_datum.year } - @statistics[:year] = stats_for_grouping(by_year) - @scores[:year] = scores_for_grouping(by_year) - @statistics[:grader] = stats_for_grader(latest_submissions) - end - -private - - def load_course_config - course = @course.name.gsub(/[^A-Za-z0-9]/, "") - begin - load(File.join(Rails.root, "courseConfig", - "#{course}.rb")) - eval("extend(Course#{course.camelize})") - rescue LoadError, SyntaxError, NameError, NoMethodError => e - @error = e - end - end - -# Scores for grouping - def scores_for_grouping(grouping) - result = {} - grouping.keys.compact.sort.each do |group| - scoreresult = {} - problem_scores = problem_scores_for_group(grouping, group) - @assessment.problems.each do |problem| - scoreresult[problem.name] = problem_scores[problem.id] - end - result[group] = scoreresult - end - result - end - - # Problem scores for grouping - def problem_scores_for_group(grouping, group) - problem_scores = {} - - @assessment.problems.each do |problem| - problem_scores[problem.id] = [] - end - problem_scores[:total] = [] - - grouping[group].each do |submission| - next unless submission.course_user_datum.student? - # TODO(jezimmer): Find a more permanent fix (see #529) - #next unless submission.special_type == Submission::NORMAL - - submission.scores.each do |score| - problem_scores[score.problem_id] << score.score - end - problem_scores[:total] << submission.final_score(@cud) - end - problem_scores - end - -# Stats for grouping - def stats_for_grouping(grouping) - result = {} - problem_id_to_name = @assessment.problem_id_to_name - stats = Statistics.new - # There can be null keys here because some of the - # values we group by are nullable in the DB. We - # shouldn't show those. - grouping.keys.compact.sort.each do |group| - problem_scores = problem_scores_for_group(grouping,group) - # Need the problems to be in the right order. - problem_stats = {} - # seems like we always index with 1 - @assessment.problems.each do |problem| - problem_stats[problem.name] = stats.stats(problem_scores[problem.id]) - end - problem_stats[:Total] = stats.stats(problem_scores[:total]) - result[group] = {} - result[group][:data] = problem_stats - result[group][:total_students] = problem_scores[:total].length - end - # raise result.inspect - result - end - - # This is different from all of the others because it doesn't - # group by submission but by score (since multiple graders can - # grade problems for a single submission). - def stats_for_grader(submissions) - result = [] - problem_id_to_name = @assessment.problem_id_to_name - stats = Statistics.new - - grader_scores = {} - submissions.each do |submission| - next unless submission.special_type == Submission::NORMAL - - submission.scores.each do |score| - next if score.grader_id.nil? - if grader_scores.key? score.grader_id - grader_scores[score.grader_id] << score - else - grader_scores[score.grader_id] = [score] - end - end - end - - grader_ids = grader_scores.keys - def find_user(i) - if i == 0 - autograder = Hash["full_name", "Autograder", - "id", 0, - "full_name_with_email", "Autograder"] - def autograder.method_missing(m) - self[m.to_s] - end - - autograder - else - @course.course_user_data.find(i) - end - end - grader_ids.filter! { |i| i != -1 } - graders = grader_ids.map(&method(:find_user)) - graders = graders.compact - graders.sort! { |g1, g2| g1.full_name <=> g2.full_name } - - graders.each do |grader| - scores = grader_scores[grader["id"]] - - problem_scores = {} - @assessment.problems.each do |problem| - problem_scores[problem.id] = [] - end - - scores.each do |score| - problem_scores[score.problem_id] << score.score - end - - problem_stats = [] - @assessment.problems.each do |problem| - problem_stats << [problem.name, stats.stats(problem_scores[problem.id])] - end - - result << [grader.full_name_with_email, problem_stats] - end - result - end - - # TODO - def load_gradesheet_data - @start = Time.now - id = @assessment.id - - # lecture/section filter - o = params[:section] ? { - conditions: { assessment_id: id, course_user_data: { lecture: @cud.lecture, section: @cud.section } } - } : { - conditions: { assessment_id: id } - } - - # currently loads *all* assessment AUDs, scores in spite of the section filter - # but that's okay, it only takes a couple 10ms - cache = AssociationCache.new(@course) do |_| - _.load_course_user_data - _.load_auds - _.load_latest_submissions o - _.load_latest_submission_scores(conditions: { submissions: { assessment_id: id } }) - _.load_assessments - end - - @assessment = cache.assessments[@assessment.id] - @submissions = cache.latest_submissions.values - @section_filter = params[:section] - end -end +require "csv" +require "utilities" + +module AssessmentGrading + # Export all scores for an assessment for all students as CSV + def bulkExport + # generate CSV + csv = render_to_string layout: false + + # send CSV file + timestamp = Time.now.strftime "%Y%m%d%H%M" + file_name = "#{@course.name}_#{@assessment.name}_#{timestamp}.csv" + send_data csv, filename: file_name + end + + # Allows the user to upload multiple scores or comments from a CSV file + def bulkGrade + return unless request.post? + + # part 1: submitting a CSV for processing and returning errors in CSV + if params[:upload] + # get data type + @data_type = params[:upload][:data_type].to_sym + unless @data_type == :scores || @data_type == :feedback + flash[:error] = "bulkGrade: invalid data_type received from client" + redirect_to(action: :bulkGrade) && return + end + + # get CSV + csv_file = params[:upload][:file] + if csv_file + @csv = csv_file.read + else + flash[:error] = "You need to choose a CSV file to upload." + redirect_to(action: :bulkGrade) && return + end + + # process CSV + success, entries = parse_csv @csv, @data_type + if success + @entries = entries + @valid_entries = valid_entries? entries + else + redirect_to(action: :bulkGrade) && return + end + end + end + + # part 2: confirming a CSV upload and saving data + def bulkGrade_complete + redirect_to(action: :bulkGrade) && return unless request.post? + + # retrieve entries CSV from hidden field in form + csv = params[:confirm][:bulkGrade_csv] + data_type = params[:confirm][:bulkGrade_data_type].to_sym + unless csv && data_type + flash[:error] = "Please try again." + redirect_to(action: :bulkGrade) && return + end + + success, @entries = parse_csv csv, data_type + if !success + flash[:error] = "bulkGrade_complete: invalid csv returned from client" + redirect_to(action: :bulkGrade) && return + elsif !valid_entries?(@entries) + flash[:error] = "bulkGrade_complete: invalid entries returned from client" + redirect_to(action: :bulkGrade) && return + end + + # save data + unless save_entries @entries, data_type + flash[:error] = "Failed to Save Entries" + redirect_to(action: :bulkGrade) && return + end + end + +private + + def valid_entries?(entries) + entries.reduce true do |acc, entry| + acc && valid_entry?(entry) + end + end + + def valid_entry?(entry) + entry.values.reduce true do |acc, v| + acc && (case v + when Hash + !v.include?(:error) && valid_entry?(v) + else + true + end) + end + end + + def save_entries(entries, data_type) + asmt = @assessment + + begin + User.transaction do + entries.each do |entry| + user = CourseUserDatum.joins(:user) + .find_by(users: { email: entry[:email] }, course: asmt.course) + + aud = AssessmentUserDatum.get asmt.id, user.id + if entry[:grade_type] + aud.grade_type = AssessmentUserDatum::GRADE_TYPE_MAP[entry[:grade_type]] + aud.save! + end + + unless sub = aud.latest_submission + sub = asmt.submissions.create!( + course_user_datum_id: user.id, + assessment_id: asmt.id, + submitted_by_id: @cud.id, + created_at: [Time.current, asmt.due_at].min + ) + end + + entry[:data].each do |problem_name, datum| + next unless datum + + problem = asmt.problems.find_by_name problem_name + + score = sub.scores.find_by_problem_id problem.id + unless score + score = sub.scores.new( + grader_id: @cud.id, + problem_id: problem.id + ) + end + + case data_type + when :scores + score.score = datum + when :feedback + score.feedback = datum.gsub("\\n", "\n").gsub("\\t", "\t") + end + + updateScore user.id, score + end + end # entries.each + end # User.transaction + + true + rescue ActiveRecord::ActiveRecordError => e + flash[:error] = "An error occurred: #{e}" + + false + end + end + + def parse_csv(csv, data_type) + # inputs for parse_csv_row + problems = @assessment.problems + emails = Set.new(CourseUserDatum.joins(:user).where(course: @assessment.course).map &:email) + + # process CSV + entries = [] + begin + CSV.parse(csv, skip_blanks: true) do |row| + entries << parse_csv_row(row, data_type, problems, emails) + end + rescue CSV::MalformedCSVError => e + flash[:error] = "Failed to parse CSV -- make sure the grades " \ + "are formatted correctly:
#{e}
" + flash[:html_safe] = true + return false, [] + end + + [true, entries] + end + + def parse_csv_row(row, kind, problems, emails) + row = row.dup + + email = row.shift.to_s + data = row.shift problems.count + grade_type = row.shift.to_s + + # to be returned + processed = {} + processed[:extra_cells] = row if row.length > 0 # currently unused + + # Checking that emails are valid + processed[:email] = if email.blank? + { error: nil } + elsif emails.include? email + email + else + { error: email } + end + + # data + data.map! do |datum| + if datum.blank? + nil + else + case kind + when :scores + Float(datum) rescue({ error: datum }) + when :feedback + datum + end + end + end + + # pad data with nil until there are problems.count elements + data.fill nil, data.length, problems.count - data.length + + processed[:data] = {} + problems.each_with_index { |problem, i| processed[:data][problem.name] = data[i] } + + # grade type + processed[:grade_type] = if grade_type.blank? + nil + elsif AssessmentUserDatum::GRADE_TYPE_MAP.key? grade_type.to_sym + grade_type.to_sym + else + { error: grade_type } + end + + processed + end + +public + + def quickSetScore + return unless request.post? + return unless params[:submission_id] + return unless params[:problem_id] + + # get submission and problem IDs + sub_id = params[:submission_id].to_i + prob_id = params[:problem_id].to_i + + # find existing score for this problem, if there's one + # otherwise, create it + score = Score.find_or_initialize_by_submission_id_and_problem_id(sub_id, prob_id) + + score.grader_id = @cud.id + score.score = params[:score].to_f + + updateScore(score.submission.course_user_datum_id, score) + + render plain: score.score + + # see http://stackoverflow.com/questions/6163125/duplicate-records-created-by-find-or-create-by + # and http://barelyenough.org/blog/2007/11/activerecord-race-conditions/ + # and http://stackoverflow.com/questions/5917355/find-or-create-race-conditions + rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordInvalid => error + @retries_left ||= 2 + retry unless (@retries_left -= 1) < 0 + raise error + end + + def quickSetScoreDetails + return unless request.post? + return unless params[:submission_id] + return unless params[:problem_id] + # get submission and problem IDs + sub_id = params[:submission_id].to_i + prob_id = params[:problem_id].to_i + + + # find existing score for this problem, if there's one + # otherwise, create it + score = Score.find_or_initialize_by_submission_id_and_problem_id(sub_id, prob_id) + + score.grader_id = @cud.id + score.feedback = params[:feedback] + score.released = params[:released] + + updateScore(score.submission.course_user_datum_id, score) + + render plain: score.id + + # see http://stackoverflow.com/questions/6163125/duplicate-records-created-by-find-or-create-by + # and http://barelyenough.org/blog/2007/11/activerecord-race-conditions/ + # and http://stackoverflow.com/questions/5917355/find-or-create-race-conditions +rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordInvalid => error + @retries_left ||= 2 + retry unless (@retries_left -= 1) < 0 + raise error +end + + def submission_popover + submission = Submission.find_by(id: params[:submission_id].to_i) + if submission + render partial: "popover", locals: { s: submission } + else + render plain: "Submission not found", status: :not_found + end + end + + def excuse_popover + submission = Submission.find(params[:submission_id].to_i) + cud = CourseUserDatum.find(submission.course_user_datum_id) + email = User.find(cud.user_id).email + render partial: "excuse_popover", + locals: { submission: submission, email: email } + end + + def score_grader_info + score = Score.find(params[:score_id]) + grader = (if score then score.grader else nil end) + grader_info = "" + if grader + grader_info = grader.full_name_with_email + end + + feedback = score.feedback + response = { "grader" => grader_info, "feedback" => feedback, "score" => score.score } + render json: response + end + + def viewGradesheet + load_gradesheet_data + end + + def quickGetTotal + return unless params[:submission_id] + + # get submission and problem IDs + sub_id = params[:submission_id].to_i + + render plain: Submission.find(sub_id).final_score(@cud) + end + + def statistics + load_course_config + latest_submissions = @assessment.submissions.latest_for_statistics.includes(:scores, :course_user_datum) + #latest_submissions = @assessment.submissions.latest.includes(:scores, :course_user_datum) + + # Each value other than for :all is of the form + # [[, {:mean, :median, :max, :min, :stddev}]...] + # for each group. :all has just the hash. + @statistics = {} + @scores = {} + # Rather than special case this, we just index into the result. + by_assessment = latest_submissions.group_by { |s| s.assessment.name } + assessment_stats = stats_for_grouping(by_assessment) + all_grouping = assessment_stats[assessment_stats.keys[0]] + + if all_grouping.nil? + @statistics[:all] = [] + @scores[:all] = [] + else + @statistics[:all] = all_grouping[:data] + @scores[:all] = all_grouping[1] + end + + by_course_number = latest_submissions.group_by { |s| s.course_user_datum.course_number } + @statistics[:course_number] = stats_for_grouping(by_course_number) + @scores[:course_number] = scores_for_grouping(by_course_number) + + by_lecture = latest_submissions.group_by { |s| s.course_user_datum.lecture } + @statistics[:lecture] = stats_for_grouping(by_lecture) + @scores[:lecture] = scores_for_grouping(by_lecture) + + by_section = latest_submissions.group_by { |s| s.course_user_datum.section } + @statistics[:section] = stats_for_grouping(by_section) + @scores[:section] = scores_for_grouping(by_section) + + by_school = latest_submissions.group_by { |s| s.course_user_datum.school } + @statistics[:school] = stats_for_grouping(by_school) + @scores[:school] = scores_for_grouping(by_school) + + by_major = latest_submissions.group_by { |s| s.course_user_datum.major } + @statistics[:major] = stats_for_grouping(by_major) + @scores[:major] = scores_for_grouping(by_major) + + by_year = latest_submissions.group_by { |s| s.course_user_datum.year } + @statistics[:year] = stats_for_grouping(by_year) + @scores[:year] = scores_for_grouping(by_year) + @statistics[:grader] = stats_for_grader(latest_submissions) + end + +private + + def load_course_config + course = @course.name.gsub(/[^A-Za-z0-9]/, "") + begin + load(File.join(Rails.root, "courseConfig", + "#{course}.rb")) + eval("extend(Course#{course.camelize})") + rescue LoadError, SyntaxError, NameError, NoMethodError => e + @error = e + end + end + +# Scores for grouping + def scores_for_grouping(grouping) + result = {} + grouping.keys.compact.sort.each do |group| + scoreresult = {} + problem_scores = problem_scores_for_group(grouping, group) + @assessment.problems.each do |problem| + scoreresult[problem.name] = problem_scores[problem.id] + end + result[group] = scoreresult + end + result + end + + # Problem scores for grouping + def problem_scores_for_group(grouping, group) + problem_scores = {} + + @assessment.problems.each do |problem| + problem_scores[problem.id] = [] + end + problem_scores[:total] = [] + + grouping[group].each do |submission| + next unless submission.course_user_datum.student? + # TODO(jezimmer): Find a more permanent fix (see #529) + #next unless submission.special_type == Submission::NORMAL + + submission.scores.each do |score| + problem_scores[score.problem_id] << score.score + end + problem_scores[:total] << submission.final_score(@cud) + end + problem_scores + end + +# Stats for grouping + def stats_for_grouping(grouping) + result = {} + problem_id_to_name = @assessment.problem_id_to_name + stats = Statistics.new + # There can be null keys here because some of the + # values we group by are nullable in the DB. We + # shouldn't show those. + grouping.keys.compact.sort.each do |group| + problem_scores = problem_scores_for_group(grouping,group) + # Need the problems to be in the right order. + problem_stats = {} + # seems like we always index with 1 + @assessment.problems.each do |problem| + problem_stats[problem.name] = stats.stats(problem_scores[problem.id]) + end + problem_stats[:Total] = stats.stats(problem_scores[:total]) + result[group] = {} + result[group][:data] = problem_stats + result[group][:total_students] = problem_scores[:total].length + end + # raise result.inspect + result + end + + # This is different from all of the others because it doesn't + # group by submission but by score (since multiple graders can + # grade problems for a single submission). + def stats_for_grader(submissions) + result = [] + problem_id_to_name = @assessment.problem_id_to_name + stats = Statistics.new + + grader_scores = {} + submissions.each do |submission| + next unless submission.special_type == Submission::NORMAL + + submission.scores.each do |score| + next if score.grader_id.nil? + if grader_scores.key? score.grader_id + grader_scores[score.grader_id] << score + else + grader_scores[score.grader_id] = [score] + end + end + end + + grader_ids = grader_scores.keys + def find_user(i) + if i == 0 + autograder = Hash["full_name", "Autograder", + "id", 0, + "full_name_with_email", "Autograder"] + def autograder.method_missing(m) + self[m.to_s] + end + + autograder + else + @course.course_user_data.find(i) + end + end + grader_ids.filter! { |i| i != -1 } + graders = grader_ids.map(&method(:find_user)) + graders = graders.compact + graders.sort! { |g1, g2| g1.full_name <=> g2.full_name } + + graders.each do |grader| + scores = grader_scores[grader["id"]] + + problem_scores = {} + @assessment.problems.each do |problem| + problem_scores[problem.id] = [] + end + + scores.each do |score| + problem_scores[score.problem_id] << score.score + end + + problem_stats = [] + @assessment.problems.each do |problem| + problem_stats << [problem.name, stats.stats(problem_scores[problem.id])] + end + + result << [grader.full_name_with_email, problem_stats] + end + result + end + + # TODO + def load_gradesheet_data + @start = Time.now + id = @assessment.id + + # lecture/section filter + o = params[:section] ? { + conditions: { assessment_id: id, course_user_data: { lecture: @cud.lecture, section: @cud.section } } + } : { + conditions: { assessment_id: id } + } + + # currently loads *all* assessment AUDs, scores in spite of the section filter + # but that's okay, it only takes a couple 10ms + cache = AssociationCache.new(@course) do |_| + _.load_course_user_data + _.load_auds + _.load_latest_submissions o + _.load_latest_submission_scores(conditions: { submissions: { assessment_id: id } }) + _.load_assessments + end + + @assessment = cache.assessments[@assessment.id] + @submissions = cache.latest_submissions.values + @section_filter = params[:section] + end +end diff --git a/app/controllers/assessments_controller.rb b/app/controllers/assessments_controller.rb index 37786e070..520353461 100755 --- a/app/controllers/assessments_controller.rb +++ b/app/controllers/assessments_controller.rb @@ -40,6 +40,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 diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index 3a124099e..a6e93ddf3 100755 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -17,6 +17,20 @@ 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 @@ -99,11 +113,11 @@ def create @submission.submitted_by_id = @cud.id next unless @submission.save! # Now we have a version number! - if params[:submission]["file"].present? + if params[:submission]["file"]&.present? @submission.save_file(params[:submission]) end end - flash[:success] = "#{pluralize(cud_ids.size, 'Submission')} Created" + flash[:success] = "#{pluralize(cud_ids.size.to_s, 'Submission')} Created" redirect_to course_assessment_submissions_path(@course, @assessment) end @@ -145,9 +159,9 @@ def update def destroy if params[:yes] if @submission.destroy - flash[:success] = "Submission successfully destroyed" + flash[:success] = "Submission successfully destroyed." else - flash[:error] = "Submission failed to be destroyed" + flash[:error] = "Submission failed to be destroyed." end else flash[:error] = "There was an error deleting the submission." @@ -156,6 +170,41 @@ def destroy @submission.assessment)) && return end + 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] = "#{scount} #{"submission".pluralize(scount)} destroyed. #{fcount} #{"submission".pluralize(fcount)} failed." + else + flash[:error] = "#{scount} #{"submission".pluralize(scount)} destroyed. #{fcount} #{"submission".pluralize(fcount)} 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 @@ -185,26 +234,26 @@ 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." @assessment.errors.full_messages.each do |msg| flash[:error] += "
#{msg}" end flash[:html_safe] = true - redirect_to failure_redirect_path and return end 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] @@ -213,6 +262,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) @@ -227,7 +280,52 @@ 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 @@ -243,6 +341,88 @@ def tweak_total 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] = "#{pluralize(auds_to_excuse.size.to_s, '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. @@ -265,7 +445,7 @@ def download # 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 @@ -330,7 +510,7 @@ def view @header_position = params[:header_position].to_i rescue StandardError flash[:error] = "Could not read archive." - redirect_to [@course, @assessment] and return false + redirect_to course_assessment_submissions_path(@course, @assessment) and return false end else @files = [{ @@ -366,7 +546,7 @@ def view unless file && pathname flash[:error] = "Could not read archive." - redirect_to [@course, @assessment] and return false + redirect_to course_assessment_submissions_path(@course, @assessment) and return false end @displayFilename = pathname @@ -486,7 +666,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 @@ -566,8 +746,8 @@ def view annotations_by_file = annotations_by_file.sort_by{ |a| [a[6], a[2]] }.group_by { |a| a[6] } @problemAnnotations[problem] = { - global_annotations:, - annotations_by_file: + global_annotations: global_annotations, + annotations_by_file: annotations_by_file } end @@ -621,8 +801,8 @@ def view matchedVersions << { version: submission.version, - header_position:, - submission: + header_position: header_position, + submission: submission } end @@ -656,6 +836,14 @@ def view 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]) @@ -679,7 +867,7 @@ def download_filename(path, student_email) def get_submission_file unless @submission.filename flash[:error] = "No file associated with submission." - redirect_to [@course, @assessment] and return false + redirect_to course_assessment_submissions_path(@course, @assessment) and return false end @filename = @submission.handin_file_path @@ -693,7 +881,7 @@ def get_submission_file unless File.exist? @filename flash[:error] = "Could not find submission file." - redirect_to [@course, @assessment] and return false + redirect_to course_assessment_submissions_path(@course, @assessment) and return false end true diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index c5df5773d..bc2f3f010 100755 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -159,8 +159,6 @@ def external_stylesheet_link_tag(library) when "semantic-ui" version = "2.4.1" stylesheet_link_tag "#{cloudflare}/semantic-ui/#{version}/semantic.min.css" - when "datatables-rows" - stylesheet_link_tag "https://cdn.datatables.net/v/dt/dt-1.13.4/b-2.3.6/rg-1.3.1/datatables.min.css" end end @@ -181,11 +179,6 @@ def external_javascript_include_tag(library) when "jquery.dataTables" version = "1.13.4" javascript_include_tag "https://cdn.datatables.net/#{version}/js/jquery.dataTables.min.js" - when "datatables-buttons" - version = "2.3.6" - javascript_include_tag "https://cdn.datatables.net/buttons/#{version}/js/dataTables.buttons.min.js" - when "datatables-rows" - javascript_include_tag "https://cdn.datatables.net/v/dt/dt-1.13.4/b-2.3.6/rg-1.3.1/datatables.min.js" when "flatpickr" version = "4.6.13" javascript_include_tag "#{cloudflare}/flatpickr/#{version}/flatpickr.min.js" diff --git a/app/views/assessments/_excuse_popover.html.erb b/app/views/assessments/_excuse_popover.html.erb new file mode 100644 index 000000000..03626eb7c --- /dev/null +++ b/app/views/assessments/_excuse_popover.html.erb @@ -0,0 +1,16 @@ +
+
+ MARK UNEXCUSED? +
+
+ <%= email %> +
+
+ CANCEL + <%= link_to "CONFIRM".html_safe, + unexcuse_course_assessment_submissions_path(@course, @assessment, submission: submission), + { method: "post", + title: "Unexcuse this student", + class: "btn excuse-popover-confirm" } %> +
+
\ No newline at end of file diff --git a/app/views/assessments/show.html.erb b/app/views/assessments/show.html.erb index 9f1ff7306..e1f20039b 100755 --- a/app/views/assessments/show.html.erb +++ b/app/views/assessments/show.html.erb @@ -1,6 +1,20 @@ <% content_for :javascripts do %> - <%= javascript_include_tag 'validateIntegrity' %> + <%= javascript_include_tag 'validateIntegrity.js' %> <%= javascript_include_tag 'collapsible' %> + <% end %> <%# Make sure these options are not on the general user options list. %> @@ -18,10 +32,10 @@ <% @list.delete("submission") %> <% @list.delete("reload") %> -
+
-

+

<%= @assessment.display_name %>

<%= @assessment.description %>

@@ -97,7 +111,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/index.html.erb b/app/views/submissions/index.html.erb index dc8306455..ba7ac135e 100755 --- a/app/views/submissions/index.html.erb +++ b/app/views/submissions/index.html.erb @@ -2,27 +2,41 @@ <% content_for :stylesheets do %> <%= stylesheet_link_tag "datatable.adapter" %> + <%= stylesheet_link_tag "datatables-rows" %> <%= stylesheet_link_tag "manage_submissions" %> <%= stylesheet_link_tag "annotations" %> <%= external_stylesheet_link_tag "jquery-ui" %> <% end %> <% content_for :javascripts do %> @@ -47,47 +61,68 @@ var currentHeaderPos = null; new_course_assessment_submission_path(@course, @assessment), { title: "Create a new submission for a student, with an option to submit a handin file on their behalf", class: "btn submissions-main" } %> -
    +
    +
    <%= link_to "file_downloadDownload Final Submissions".html_safe, - downloadAll_course_assessment_submissions_path(@course, @assessment, { final: true }), - { title: "Download final submissions from each student", - class: "btn submissions-main" } %> + download_all_course_assessment_submissions_path(@course, @assessment, final: true), + { + title: @submissions.any? ? "Download final submissions from each student" : "No submissions available to download", + class: "btn submissions-main #{'disabled' unless @submissions.any?}" + } %>
    -
    + + +
    <%= link_to "peopleMissing Submissions".html_safe, missing_course_assessment_submissions_path(@course, @assessment), - { title: "List the students who have not submitted anything. You'll be given the option to create new submissions for the missing students", + { title: "List the students who have not submitted anything", class: "btn submissions-main" } %> -
    -
    +
    + +
    <%= link_to "eventManage Extensions".html_safe, - course_assessment_extensions_path(@course, @assessment), - { title: "List the students who have not submitted anything. You'll be given the option to create new submissions for the missing students", + [@course, @assessment, :extensions], + { title: "Manage extensions for this assignment", class: "btn submissions-main" } %> -
    -
    -
    - <% if @autograded then %> -
    - <%= link_to "Regrade All", - regradeAll_course_assessment_path(@course, @assessment), - { method: :post, - title: "Regrade the most recent submission from each student", - confirm: "Are you sure you want to do this? It will regrade the most recent submission from each student, which might take a while.", - class: "btn" } %> -
    -
    - - <%= link_to "Regrade 0", - regradeBatch_course_assessment_path(@course, @assessment), - { method: :post, - title: "Regrade the most recent submission from each student", - class: "btn float-right", - id: "batch-regrade", - style: "display:none;" } %>
    - <% end %> +
    + +<%# Selected buttons, hidden so HTML can be accessed in DataTables %> +
    +
    + <%= link_to "cachedRegrade Selected".html_safe, + regradeBatch_course_assessment_path(@course, @assessment), + { method: :post, + title: "Regrade selected submissions", + class: "btn submissions-selected" } %> +
    + +
    + <%= link_to "delete_outlineDelete Selected".html_safe, + destroy_batch_course_assessment_submissions_path(@course, @assessment, @submissions), + { method: :post, + title: "Delete selected submissions", + class: "btn submissions-selected", + data: {confirm: "Deleting will delete all checked submissions and cannot be undone. Are you sure you want to delete these submissions?"} } %> +
    + +
    + <%= link_to "downloadDownload Selected".html_safe, + download_batch_course_assessment_submissions_path(@course, @assessment, @submissions), + { method: :get, + title: "Download selected submissions", + class: "btn submissions-selected" } %> +
    + +
    + <%= link_to "doneExcuse Selected".html_safe, + excuse_batch_course_assessment_submissions_path(@course, @assessment, @submissions), + { method: :post, + title: "Excuse selected submissions", + class: "btn submissions-selected" } %> +
    +
    @@ -99,7 +134,16 @@ var currentHeaderPos = null; "Actions"] %> - + <%# Select all checkbox in header row %> + + <%# Table headers %> <% for header in headers %> - + + + <% for submission in @submissions %> @@ -124,18 +170,23 @@ var currentHeaderPos = null;
    <%# Submitted By %> <%# Version %> @@ -168,26 +219,28 @@ var currentHeaderPos = null; + <%# Actions %> - - <% end %> <%# End loop over submissions %> + <% end %>
    +
    + +
    +
    @@ -115,7 +159,9 @@ var currentHeaderPos = null;
    - <%= [submission.course_user_datum.first_name, submission.course_user_datum.last_name].reject(&:blank?).join(' ') %> -
    - <%= link_to submission.course_user_datum.email, - history_course_assessment_path(@course, @assessment, cud_id: submission.course_user_datum_id, partial: true), - { remote: true, class: :trigger } %> +
    + <%= [submission.course_user_datum.first_name, submission.course_user_datum.last_name].reject(&:blank?).join(' ') %> + <% if @excused_cids.include? submission.course_user_datum_id %> + EXCUSED +
    +
    +
    + <% end %> +
    + <%= submission.course_user_datum.email %>
    <% if submission.filename then %>
    - <%= link_to "zoom_in".html_safe, - [:view, @course, @assessment, submission], - {:title=>"View the file for this submission", - :class=>"btn small"} %> + <%= link_to "zoom_in".html_safe, + view_course_assessment_submission_url(@course, @assessment, submission), + { title: "View the file for this submission", + class: "btn small" } %>

    View File

    <% else %> None <% end %>
    - <% if @autograded and submission.version > 0 then %> + + <% if @autograded then %>
    - <%= button_to [:regrade, @course, @assessment, submission_id: submission.id], - :method => :post, - :title=>"Rerun the autograder on this submission", - :class=>"btn small" do %> - autorenew - <% end %> +
    + <%= link_to "autorenew".html_safe, + regradeBatch_course_assessment_path(@course, @assessment, {submission_ids: [submission.id]}), + { method: :post, + title: "Regrade selected submissions", + class: "btn small" } %> +

    Regrade

    <% end %> @@ -200,7 +253,7 @@ var currentHeaderPos = null;
    diff --git a/config/routes.rb b/config/routes.rb index 98401a654..b5ab17a77 100755 --- a/config/routes.rb +++ b/config/routes.rb @@ -148,7 +148,12 @@ end collection do - get "downloadAll" + get "download_all" + post "destroy_batch" + get "download_batch" + get "popover" + post "excuse_batch" + post "unexcuse" get "missing" get "score_details" end @@ -192,6 +197,9 @@ get "score_grader_info" get "submission_popover" + # Manage submissions excuse students + get "excuse_popover" + # remote calls match "local_submit", via: [:get, :post] get "log_submit" From 00e126a1700d9794ec847ae7ac4dea12a8eeb2c9 Mon Sep 17 00:00:00 2001 From: Kester Date: Tue, 17 Dec 2024 22:23:31 -0500 Subject: [PATCH 08/14] Merge conflicts for env template and gitignore --- .env.template | 2 +- .gitignore | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.env.template b/.env.template index 59edb0f26..e5574aa32 100644 --- a/.env.template +++ b/.env.template @@ -18,7 +18,7 @@ DEVISE_SECRET_KEY= GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= -# Used to connect Tango with autolab +# Used to connect Tango with Autolab # Ensure variables are consistent with your Tango install's `config.py` settings # Hostname for Tango RESTful API diff --git a/.gitignore b/.gitignore index 98afb7be1..d8ffb1252 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ tmp/cache/ tmp/ !tmp/restart.txt -# autolab configs +# Autolab configs config/autogradeConfig.rb assessmentConfig/ courseConfig/ @@ -12,14 +12,16 @@ config/environments/production.rb coverage/ /courses/ /courses -/gradebooks/ config/database.yml config/school.yml config/lti_config.yml -config/lti_tool_jwk.json config/lti_platform_jwk.json +config/lti_tool_jwk.json +config/smtp_config.yml +config/github_config.yml +config/oauth_config.yml -# autolab user documents +# Autolab user documents app/views/home/_topannounce.html.erb attachments/ doc/ From 605c9574dfbacc83a452955d3f6dea7d70147808 Mon Sep 17 00:00:00 2001 From: Kester Date: Tue, 17 Dec 2024 22:35:28 -0500 Subject: [PATCH 09/14] Merge conflicts for style --- app/assets/stylesheets/style.css.scss | 250 ++++++++++++++++++++++++-- 1 file changed, 238 insertions(+), 12 deletions(-) diff --git a/app/assets/stylesheets/style.css.scss b/app/assets/stylesheets/style.css.scss index 021b7fef5..c8da61309 100644 --- a/app/assets/stylesheets/style.css.scss +++ b/app/assets/stylesheets/style.css.scss @@ -686,6 +686,14 @@ ul.moss-inner-list > li > input[type="checkbox"]:checked ~ div { text-decoration: underline; } +div.field_with_errors { + display: inline; +} + +div.field_with_errors { + display: inline; +} + .table-info { margin-bottom: auto; display: flex; @@ -725,7 +733,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 +751,7 @@ table.prettyBorder { background-color: #fff; border: 1px solid #d0d0d0; &:hover { - background-color: #abcdef; + background-color: $autolab-sky-blue; } } @@ -764,8 +771,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 +782,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 +994,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 +1126,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 +1199,7 @@ label[for="switch"] { .checkbox input[type="checkbox"]:checked + label::before { border: 1px solid #bbb; + } .new_submission { @@ -1438,10 +1497,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 +1538,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 +1555,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 From d523c2ee7923acddcc9c5c19e93d9edfe1bd3f72 Mon Sep 17 00:00:00 2001 From: Kester Date: Fri, 27 Dec 2024 02:39:33 +0800 Subject: [PATCH 10/14] Fixed delete confirm button --- app/controllers/assessments_controller.rb | 15 ++++++++++++++- app/controllers/submissions_controller.rb | 8 ++++---- app/views/assessments/show.html.erb | 2 +- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/app/controllers/assessments_controller.rb b/app/controllers/assessments_controller.rb index 9c3a35567..d8eae5a01 100755 --- a/app/controllers/assessments_controller.rb +++ b/app/controllers/assessments_controller.rb @@ -216,9 +216,22 @@ def import_assessments render json: import_results end + def excuse_popover + submission_id = params[:submission_id] + @submission = Submission.find(submission_id) + @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 d136e05ef..eb19c303d 100755 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -176,7 +176,7 @@ def update action_auth_level :destroy, :instructor def destroy - if params[:yes] + if params["destroy-confirm-check"] if @submission.destroy flash[:success] = "Submission successfully destroyed." else @@ -218,9 +218,9 @@ def destroy_batch end end if fcount == 0 - flash[:success] = "#{scount} #{"submission".pluralize(scount)} destroyed. #{fcount} #{"submission".pluralize(fcount)} failed." + flash[:success] = "#{ActionController::Base.helpers.pluralize(scount, 'submission')} destroyed. #{ActionController::Base.helpers.pluralize(fcount, 'submission')} failed." else - flash[:error] = "#{scount} #{"submission".pluralize(scount)} destroyed. #{fcount} #{"submission".pluralize(fcount)} failed." + 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 @@ -413,7 +413,7 @@ def excuse_batch "#{aud.errors.full_messages.join(', ')}" end - flash[:success] = "#{pluralize(auds_to_excuse.size.to_s, 'student')} excused." + flash[:success] = "#{ActionController::Base.helpers.pluralize(auds_to_excuse.size, 'student')} excused." redirect_to course_assessment_submissions_path(@course, @assessment) end 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" } } %>
  • From f06cd71c6188eb697672031f83d30e6c27290cbe Mon Sep 17 00:00:00 2001 From: Kester Date: Fri, 27 Dec 2024 02:43:32 +0800 Subject: [PATCH 11/14] Fixed linting --- app/controllers/submissions_controller.rb | 34 +++++++++++++------ .../assessments/_excuse_popover.html.erb | 4 +-- app/views/submissions/index.html.erb | 11 +++--- .../submissions_controller_spec.rb | 4 +-- 4 files changed, 33 insertions(+), 20 deletions(-) diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index eb19c303d..510e22a57 100755 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -10,7 +10,8 @@ class SubmissionsController < ApplicationController 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 tweak_total] + view release_student_grade unrelease_student_grade + tweak_total] before_action :get_submission_file, only: %i[download view] action_auth_level :index, :instructor @@ -61,11 +62,11 @@ def score_details submission.global_annotations.empty? ? nil : submission.global_annotations.sum(:value) end - submissions = submissions.as_json(seen_by: @cud) + submissions.as_json(seen_by: @cud) render json: { submissions: submission_info, scores: submission_id_to_score_data, - tweaks: tweaks }, status: :ok + tweaks: }, status: :ok rescue StandardError => e render json: { error: e.message }, status: :not_found nil @@ -206,10 +207,12 @@ def destroy_batch 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." + 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 + submissions[0].assessment)) && return end if s.destroy scount += 1 @@ -218,9 +221,19 @@ def destroy_batch end end if fcount == 0 - flash[:success] = "#{ActionController::Base.helpers.pluralize(scount, 'submission')} destroyed. #{ActionController::Base.helpers.pluralize(fcount, 'submission')} failed." + 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." + 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 @@ -258,7 +271,6 @@ def missing 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." @assessment.errors.full_messages.each do |msg| @@ -334,7 +346,8 @@ def download_batch 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." + 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 @@ -413,7 +426,8 @@ def excuse_batch "#{aud.errors.full_messages.join(', ')}" end - flash[:success] = "#{ActionController::Base.helpers.pluralize(auds_to_excuse.size, 'student')} excused." + flash[:success] = + "#{ActionController::Base.helpers.pluralize(auds_to_excuse.size, 'student')} excused." redirect_to course_assessment_submissions_path(@course, @assessment) end diff --git a/app/views/assessments/_excuse_popover.html.erb b/app/views/assessments/_excuse_popover.html.erb index 03626eb7c..dad38737d 100644 --- a/app/views/assessments/_excuse_popover.html.erb +++ b/app/views/assessments/_excuse_popover.html.erb @@ -8,9 +8,9 @@
    CANCEL <%= link_to "CONFIRM".html_safe, - unexcuse_course_assessment_submissions_path(@course, @assessment, submission: submission), + unexcuse_course_assessment_submissions_path(@course, @assessment, submission:), { method: "post", title: "Unexcuse this student", class: "btn excuse-popover-confirm" } %>
    -
    \ No newline at end of file +
    diff --git a/app/views/submissions/index.html.erb b/app/views/submissions/index.html.erb index ba7ac135e..7741ee6e0 100755 --- a/app/views/submissions/index.html.erb +++ b/app/views/submissions/index.html.erb @@ -72,7 +72,6 @@ } %>
    -
    <%= link_to "peopleMissing Submissions".html_safe, missing_course_assessment_submissions_path(@course, @assessment), @@ -104,7 +103,7 @@ { method: :post, title: "Delete selected submissions", class: "btn submissions-selected", - data: {confirm: "Deleting will delete all checked submissions and cannot be undone. Are you sure you want to delete these submissions?"} } %> + data: { confirm: "Deleting will delete all checked submissions and cannot be undone. Are you sure you want to delete these submissions?" } } %>
    @@ -236,7 +235,7 @@
    <%= link_to "autorenew".html_safe, - regradeBatch_course_assessment_path(@course, @assessment, {submission_ids: [submission.id]}), + regradeBatch_course_assessment_path(@course, @assessment, { submission_ids: [submission.id] }), { method: :post, title: "Regrade selected submissions", class: "btn small" } %> @@ -246,9 +245,9 @@ <% end %>
    <%= link_to "delete_outline".html_safe, - destroyConfirm_course_assessment_submission_path(@course, @assessment, submission), - {:title=>"Destroy this submission forever", - :class=>"btn small"} %> + destroyConfirm_course_assessment_submission_path(@course, @assessment, submission), + { title: "Destroy this submission forever", + class: "btn small" } %>

    Delete

    diff --git a/spec/controllers/submissions_controller_spec.rb b/spec/controllers/submissions_controller_spec.rb index c8e1e0a1c..a90034a2f 100644 --- a/spec/controllers/submissions_controller_spec.rb +++ b/spec/controllers/submissions_controller_spec.rb @@ -10,9 +10,9 @@ sign_in(user) cud = get_first_cud_by_uid(user) cid = get_first_cid_by_uid(user) - course_name = Course.find(cid).name + Course.find(cid).name assessment_id = get_first_aid_by_cud(cud) - assessment_name = Assessment.find(assessment_id).name + Assessment.find(assessment_id).name get :index, params: { course_name: @course.name, assessment_name: @assessment.name } expect(response).to be_successful expect(response.body).to match(/Manage Submissions/m) From f6a0c05cc80064ec3c65f40d4fb3c5f96507e3f2 Mon Sep 17 00:00:00 2001 From: Kester Date: Thu, 2 Jan 2025 17:34:50 +0800 Subject: [PATCH 12/14] Fixed tests and linting --- app/views/submissions/index.html.erb | 2 -- spec/controllers/submissions_controller_spec.rb | 4 ++-- spec/features/assessment_spec.rb | 3 ++- spec/features/manage_submissions_spec.rb | 8 +------- 4 files changed, 5 insertions(+), 12 deletions(-) diff --git a/app/views/submissions/index.html.erb b/app/views/submissions/index.html.erb index 7741ee6e0..28c0c055b 100755 --- a/app/views/submissions/index.html.erb +++ b/app/views/submissions/index.html.erb @@ -2,7 +2,6 @@ <% content_for :stylesheets do %> <%= stylesheet_link_tag "datatable.adapter" %> - <%= stylesheet_link_tag "datatables-rows" %> <%= stylesheet_link_tag "manage_submissions" %> <%= stylesheet_link_tag "annotations" %> <%= external_stylesheet_link_tag "jquery-ui" %> @@ -49,7 +48,6 @@ <%= javascript_include_tag "annotations_helpers" %> <%= javascript_include_tag "annotations_popup" %> <%= javascript_include_tag "manage_submissions" %> - <%= external_javascript_include_tag "datatables-rows" %> <% end %>

    Manage Submissions

    diff --git a/spec/controllers/submissions_controller_spec.rb b/spec/controllers/submissions_controller_spec.rb index a90034a2f..0b8b7900d 100644 --- a/spec/controllers/submissions_controller_spec.rb +++ b/spec/controllers/submissions_controller_spec.rb @@ -147,7 +147,7 @@ submission = get_first_submission_by_assessment(@assessment) expect do post :destroy, params: { course_name: @course.name, assessment_name: @assessment.name, - id: submission.id } + id: submission.id, "destroy-confirm-check": "filled-in" } end.to change(Submission, :count).by(-1) expect(response).to have_http_status(302) expect(flash[:success]) @@ -160,7 +160,7 @@ submission = Submission.where(course_user_datum_id: get_first_cud_by_uid(user.id)).first expect do post :destroy, params: { course_name: @course.name, assessment_name: @assessment.name, - id: submission.id } + id: submission.id, "destroy-confirm-check": "filled-in" } end.to change(Submission, :count).by(0) expect(response).to have_http_status(302) expect(flash[:error]) diff --git a/spec/features/assessment_spec.rb b/spec/features/assessment_spec.rb index b54e97fad..7463ec4fd 100644 --- a/spec/features/assessment_spec.rb +++ b/spec/features/assessment_spec.rb @@ -100,7 +100,8 @@ click_on "View Gradesheet" # click on student's submission - td = page.find(:css, 'td.id', text: student.email) + a = page.find('#grades td.id > a.email', text: student.email) + td = a.find(:xpath, './parent::td') tr = td.find(:xpath, './parent::tr') within tr do click_on "View Source" diff --git a/spec/features/manage_submissions_spec.rb b/spec/features/manage_submissions_spec.rb index 4585de5a6..d5681efa2 100644 --- a/spec/features/manage_submissions_spec.rb +++ b/spec/features/manage_submissions_spec.rb @@ -26,15 +26,9 @@ click_on assessment_name click_on "Manage submissions" - first(:link, "Edit the grading properties of this submission").click - fill_in("submission_notes", with: "test notes") - fill_in("submission_tweak_attributes_value", with: "1.0") - - click_on("Update Submission") # TODO: check values are okay - # seems like there's a bug currently because after submitting, instructor - # gets redirected to their own submission history instead of back to manage submissions + # Check user flow for adding tweaks is okay end end end From 44c33e8df5cc6b8bebea5019eb56a1f9598adbf7 Mon Sep 17 00:00:00 2001 From: Kester Date: Mon, 13 Jan 2025 14:41:03 -0500 Subject: [PATCH 13/14] Removed unnecessary duplicate styles --- app/assets/stylesheets/style.css.scss | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/assets/stylesheets/style.css.scss b/app/assets/stylesheets/style.css.scss index c8da61309..0a0036ee3 100644 --- a/app/assets/stylesheets/style.css.scss +++ b/app/assets/stylesheets/style.css.scss @@ -690,10 +690,6 @@ div.field_with_errors { display: inline; } -div.field_with_errors { - display: inline; -} - .table-info { margin-bottom: auto; display: flex; From 088cfdcc0389dc4220dedcc6e82e65c83345068c Mon Sep 17 00:00:00 2001 From: Kester Date: Mon, 13 Jan 2025 14:47:33 -0500 Subject: [PATCH 14/14] Added checks for excuse popover --- app/controllers/assessments_controller.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/controllers/assessments_controller.rb b/app/controllers/assessments_controller.rb index d8eae5a01..b5f97ca5a 100755 --- a/app/controllers/assessments_controller.rb +++ b/app/controllers/assessments_controller.rb @@ -219,6 +219,10 @@ def import_assessments 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