From 0c8d66bb2a5e56f2e09438f97904fb0b46291538 Mon Sep 17 00:00:00 2001 From: Rick Reilly Date: Thu, 18 Oct 2018 14:21:35 -0400 Subject: [PATCH] Add extesion engine gradebook ui in under the mastersGradebook route --- .gitignore | 1 + .../_gradebook_modal_table.underscore | 48 ++ .../gradebook/_gradebook_table.underscore | 80 +++ .../gradebook/_grading_policies.underscore | 4 + .../vendor/fixedColumns.dataTables.min.css | 1 + lms/djangoapps/instructor/views/api_urls.py | 3 +- .../views/writable_gradebook_api.py | 49 ++ lms/static/js/jquery.writable_gradebook.js | 110 +++ lms/static/js/writable_gradebook.js | 638 ++++++++++++++++++ .../courseware/writable_gradebook.html | 92 +++ package-lock.json | 25 + package.json | 2 + pavelib/assets.py | 9 + 13 files changed, 1061 insertions(+), 1 deletion(-) create mode 100644 common/static/common/templates/gradebook/_gradebook_modal_table.underscore create mode 100644 common/static/common/templates/gradebook/_gradebook_table.underscore create mode 100644 common/static/common/templates/gradebook/_grading_policies.underscore create mode 100644 common/static/css/vendor/fixedColumns.dataTables.min.css create mode 100644 lms/djangoapps/instructor/views/writable_gradebook_api.py create mode 100644 lms/static/js/jquery.writable_gradebook.js create mode 100644 lms/static/js/writable_gradebook.js create mode 100644 lms/templates/courseware/writable_gradebook.html diff --git a/.gitignore b/.gitignore index b9a688073b..1451b1f2fc 100644 --- a/.gitignore +++ b/.gitignore @@ -101,6 +101,7 @@ lms/static/certificates/css/ cms/static/css/ common/static/common/js/vendor/ common/static/common/css/vendor/ +common/static/common/media/ common/static/bundles webpack-stats.json diff --git a/common/static/common/templates/gradebook/_gradebook_modal_table.underscore b/common/static/common/templates/gradebook/_gradebook_modal_table.underscore new file mode 100644 index 0000000000..69b11e836d --- /dev/null +++ b/common/static/common/templates/gradebook/_gradebook_modal_table.underscore @@ -0,0 +1,48 @@ + \ No newline at end of file diff --git a/common/static/common/templates/gradebook/_gradebook_table.underscore b/common/static/common/templates/gradebook/_gradebook_table.underscore new file mode 100644 index 0000000000..a1246e2dea --- /dev/null +++ b/common/static/common/templates/gradebook/_gradebook_table.underscore @@ -0,0 +1,80 @@ + + + + + + <% var sections = studentsData[0].section_breakdown; %> + <% _.each(sections, function(section, i){ %> + <% var tooltip = section.detail; %> + + <% //The next two if statements are going to parse the section details for tooltip %> + <% if (_.str.include(tooltip, '=')) { %> + <% tooltip = tooltip.substring(0, tooltip.indexOf('=')); %> + <% } %> + + <% if (_.str.include(tooltip, '-')) { %> + <% tooltip = tooltip.substring(0, tooltip.indexOf('-')); %> + <% } %> + + <% var category = (section.category || '').replace(/[\W_]+/g, ''); %> + <% var chapterName = (section.chapter_name || '').replace(/[\W_]+/g, ''); %> + <% + var moduleId = ''; + studentsData.every(function(student) { + moduleId = student.section_breakdown[i].module_id; + return _.contains(['', 'None'], moduleId); + }); + %> + + + <% }) %> + + + + + + + <% _.each(studentsData, function(student){ %> + + + + <% _.each(student.section_breakdown, function(section){ %> + <% var category = (section.category || '').replace(/[\W_]+/g, ''); %> + <% var chapterName = (section.chapter_name || '').replace(/[\W_]+/g, ''); %> + + <% }) %> + + + + + + <% }) %> + +
<%- strLib.userHeading %> +
<%- section.label %>
+ <% if (!(section.is_average || section.is_ag)) { %> + + <% } %> +
<%- strLib.userHeading %>
<%- strLib.total %>
<%- student.full_name || student.username %> + <%- (section.grade_description || '') %> + + <%- (student.current_percent * 100).toFixed(2) %>% + + <%- (student.percent * 100).toFixed(2) %>% +
+ +
\ No newline at end of file diff --git a/common/static/common/templates/gradebook/_grading_policies.underscore b/common/static/common/templates/gradebook/_grading_policies.underscore new file mode 100644 index 0000000000..aac455ac08 --- /dev/null +++ b/common/static/common/templates/gradebook/_grading_policies.underscore @@ -0,0 +1,4 @@ +<% _.each(gradingPolicies, function(policy){ %> + <% var value = policy.replace(/[\W_]+/g, ''); %> + +<% }) %> \ No newline at end of file diff --git a/common/static/css/vendor/fixedColumns.dataTables.min.css b/common/static/css/vendor/fixedColumns.dataTables.min.css new file mode 100644 index 0000000000..71e801b53e --- /dev/null +++ b/common/static/css/vendor/fixedColumns.dataTables.min.css @@ -0,0 +1 @@ +table.DTFC_Cloned thead,table.DTFC_Cloned tfoot{background-color:white}div.DTFC_Blocker{background-color:white}div.DTFC_LeftWrapper table.dataTable,div.DTFC_RightWrapper table.dataTable{margin-bottom:0;z-index:2}div.DTFC_LeftWrapper table.dataTable.no-footer,div.DTFC_RightWrapper table.dataTable.no-footer{border-bottom:none} diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 9903cdee50..5272d7a4a9 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -4,7 +4,7 @@ Instructor API endpoint urls. from django.conf.urls import url -from lms.djangoapps.instructor.views import api, gradebook_api +from lms.djangoapps.instructor.views import api, gradebook_api, writable_gradebook_api urlpatterns = [ url(r'^students_update_enrollment$', api.students_update_enrollment, name='students_update_enrollment'), @@ -73,6 +73,7 @@ urlpatterns = [ # spoc gradebook url(r'^gradebook$', gradebook_api.spoc_gradebook, name='spoc_gradebook'), + url(r'^writable_gradebook$', writable_gradebook_api.writable_gradebook, name='writable_gradebook'), url(r'^gradebook/(?P[0-9]+)$', gradebook_api.spoc_gradebook, name='spoc_gradebook'), diff --git a/lms/djangoapps/instructor/views/writable_gradebook_api.py b/lms/djangoapps/instructor/views/writable_gradebook_api.py new file mode 100644 index 0000000000..989da8fdb4 --- /dev/null +++ b/lms/djangoapps/instructor/views/writable_gradebook_api.py @@ -0,0 +1,49 @@ +""" +Grade book view for instructor and pagination work (for grade book) +which is currently use by ccx and instructor apps. +""" +from django.db import transaction +from django.http import HttpResponseNotFound +from django.views.decorators.cache import cache_control +from opaque_keys.edx.keys import CourseKey + +from edxmako.shortcuts import render_to_response +from lms.djangoapps.courseware.courses import get_course_with_access +from lms.djangoapps.grades.config.waffle import waffle_flags, WRITABLE_GRADEBOOK +from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory +from lms.djangoapps.instructor.views.api import require_level + + +@transaction.non_atomic_requests +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@require_level('staff') +def writable_gradebook(request, course_id): + """ + Show the writable gradebook for this course: + - Only displayed to course staff + """ + course_key = CourseKey.from_string(course_id) + if not waffle_flags()[WRITABLE_GRADEBOOK].is_enabled(course_key): + return HttpResponseNotFound() + + course = get_course_with_access(request.user, 'load', course_key) + + course_grade = CourseGradeFactory().read(request.user, course) + courseware_summary = course_grade.chapter_grades.values() + course_sections = [] + + for chapter in courseware_summary: + chapter_name = chapter['display_name'] + for section in chapter['sections']: + if section.problem_scores and section.graded and chapter_name not in course_sections: + course_sections.append(chapter_name) + + return render_to_response('courseware/writable_gradebook.html', { + 'number_of_students': 2, + 'course': course, + 'course_id': course_key, + 'course_sections': course_sections, + # Checked above + 'staff_access': True, + 'ordered_grades': sorted(course.grade_cutoffs.items(), key=lambda i: i[1], reverse=True), + }) diff --git a/lms/static/js/jquery.writable_gradebook.js b/lms/static/js/jquery.writable_gradebook.js new file mode 100644 index 0000000000..73b94a3ae0 --- /dev/null +++ b/lms/static/js/jquery.writable_gradebook.js @@ -0,0 +1,110 @@ +var Gradebook = function($element, $gradeTableWrapper) { + "use strict"; + var $body = $('body'); + var $grades = $element.find('#gradebook-table-container'); + var $gradeTable = $gradeTableWrapper.find('#student-grades-table'); + var $search = $element.find('.student-search-field'); + var $leftShadow = $('
'); + var $rightShadow = $('
'); + var tableHeight = $gradeTable.height(); + var maxScroll = $gradeTable.width() - $grades.width(); + + var mouseOrigin; + var tableOrigin; + + var startDrag = function(e) { + mouseOrigin = e.pageX; + tableOrigin = $gradeTable.position().left; + $body.addClass('no-select'); + $body.bind('mousemove', onDragTable); + $body.bind('mouseup', stopDrag); + }; + + /** + * - Called when the user drags the gradetable + * - Calculates targetLeft, which is the desired position + * of the grade table relative to its leftmost position, using: + * - the new x position of the user's mouse pointer; + * - the gradebook's current x position, and; + * - the value of maxScroll (gradetable width - container width). + * - Updates the position and appearance of the gradetable. + */ + var onDragTable = function(e) { + var offset = e.pageX - mouseOrigin; + var targetLeft = clamp(tableOrigin + offset, maxScroll, 0); + updateHorizontalPosition(targetLeft); + setShadows(targetLeft); + }; + + var stopDrag = function() { + $body.removeClass('no-select'); + $body.unbind('mousemove', onDragTable); + $body.unbind('mouseup', stopDrag); + }; + + var setShadows = function(left) { + var padding = 30; + + var leftPercent = clamp(-left / padding, 0, 1); + $leftShadow.css('opacity', leftPercent); + + var rightPercent = clamp((maxScroll + left) / padding, 0, 1); + $rightShadow.css('opacity', rightPercent); + }; + + var clamp = function(val, min, max) { + if(val > max) { return max; } + if(val < min) { return min; } + return val; + }; + + /** + * - Called when the browser window is resized. + * - Recalculates maxScroll (gradetable width - container width). + * - Calculates targetLeft, which is the desired position + * of the grade table relative to its leftmost position, using: + * - the gradebook's current x position, and: + * - the new value of maxScroll + * - Updates the position and appearance of the gradetable. + */ + var onResizeTable = function() { + maxScroll = $gradeTable.width() - $grades.width(); + var targetLeft = clamp($gradeTable.position().left, maxScroll, 0); + updateHorizontalPosition(targetLeft); + setShadows(targetLeft); + }; + + /** + * - Called on table drag and on window (table) resize. + * - Takes a integer value for the desired (pixel) offset from the left + * (zero/origin) position of the grade table. + * - Uses that value to position the table relative to its leftmost + * possible position within its container. + * + * @param {Number} left - The desired pixel offset from left of the + * desired position. If the value is 0, the gradebook should be moved + * all the way to the left side relative to its parent container. + */ + var updateHorizontalPosition = function(left) { + $grades.scrollLeft(left); + }; + + var highlightRow = function() { + $element.find('.highlight').removeClass('highlight'); + + var index = $(this).index(); + $gradeTable.find('tr').eq(index + 1).addClass('highlight'); + }; + + var filter = function() { + var term = $(this).val(); + if(term.length > 0) { + $gradeTable.find('tbody tr').hide(); + } else { + $gradeTable.find('tbody tr').show(); + } + }; +}; + + + diff --git a/lms/static/js/writable_gradebook.js b/lms/static/js/writable_gradebook.js new file mode 100644 index 0000000000..67c1726d22 --- /dev/null +++ b/lms/static/js/writable_gradebook.js @@ -0,0 +1,638 @@ +function _templateLoader(templateName, staticPath, callback, errorCallback) { + var templateURL = staticPath + '/common/templates/gradebook/' + templateName + '.underscore'; + + $.ajax({ + url: templateURL, + method: 'GET', + dataType: 'html', + success: function (data) { + callback(data); + }, + error: function (errorMessage) { + console.log(errorMessage); + errorCallback('Error has occurred while rendering table.'); + } + }); +} + +function courseXblockUpdater(courseID, dataToSend, visibilityData, callback, errorCallback) { + var cleanData = {'users' : {}}; + + if (dataToSend instanceof Array) + for (var i = 0; i < dataToSend.length; i++) { + cleanData.users['id_' + dataToSend.userID] = { + 'block_id' : dataToSend[i].blockID || '', + 'grade' : dataToSend[i].grade || '', + 'max_grade' : dataToSend[i].maxGrade || null, + 'state' : dataToSend[i].state || '{}', + 'user_id' : dataToSend[i].userID || '' + }; + } + else if (dataToSend instanceof Object) + cleanData.users = dataToSend; + + var postUrl = '/api/score/courses/' + courseID; + + if (!_.isEmpty(visibilityData)) + cleanData.visibility = visibilityData; + + $.ajax({ + url: postUrl, + method: 'POST', + contentType: 'application/json; charset=utf-8', + dataType: 'json', + data: JSON.stringify(cleanData), + success: function (data) { + callback(data); + }, + error: function (errorMessage) { + console.log(errorMessage); + errorCallback('Error has occurred while updating grades.'); + } + }); +} + +function getEdxUserInfoAsObject() { + return JSON.parse($.cookie('edx-user-info').replace(/\\054/g, ',').replace(/^"(.*)"$/, '$1').replace(/\\"/g, '"')); +} + +$(document).ready(function() { + var dataTable, + $gradebookWrapper = $('.gradebook-content'), + $courseSectionFilter = $gradebookWrapper.find('#course-sections'), + $errorMessageContainer = $gradebookWrapper.find('#error-message'), + $filtersWrapper = $gradebookWrapper.find('#filters-container'), + $gradebookNotification = $gradebookWrapper.find('#gradebook-notification'), + $gradesTableWrapper = $gradebookWrapper.find('#gradebook-table-container'), + $gradingPolicyFilter = $filtersWrapper.find('#grading-policy'), + adjustedGradesData = {}, + courseID = $gradebookWrapper.attr('data-course-id'), + edxUserInfo = getEdxUserInfoAsObject(), + gradeBookData = [], + gradeOverrideObject = {}, + isFetchingComplete = false, + isFetchingSuccessful = true, + isManualGrading = false, + modalDataTable, + module_list = {'users': {}}, + renderAllGradebook = true, + sectionBlockId = '', + staticPath = $gradebookWrapper.attr('data-static-path'), + userAdjustedGrades = {}, + userAutoGrades = {}, + userComments = {}, + createMainDataTable = function(studentsDataLength) { + const $gradebookErrorMessageContainer = $gradebookWrapper.find('#gradebook-table-empty-message'); + const $studentGradesTable = $gradesTableWrapper.find('#student-grades-table'); + const options = { + fixedColumns: true, + language: { + zeroRecords: '' + }, + paging: studentsDataLength > 10, + scrollX: true + }; + dataTable = initializeDataTable($studentGradesTable, options, studentsDataLength); + setUpDataTableSearch($studentGradesTable, $gradebookErrorMessageContainer); + $studentGradesTable.on('draw.dt', displayGrades); + }, + createModalTable = function(studentsDataLength) { + const $gradeOverrideModalTable = $gradesTableWrapper.find('#grade-override-modal-table'); + const $modalErrorMessageContainer = $gradesTableWrapper.find('#modal-table-empty-message'); + const options = { + columnDefs: [{ + orderable: false, + targets: 3 + }], + language: { + zeroRecords: '' + }, + paging: studentsDataLength > 10 + }; + modalDataTable = initializeDataTable($gradeOverrideModalTable, options, studentsDataLength); + setUpDataTableSearch($gradeOverrideModalTable, $modalErrorMessageContainer); + }, + destroyDataTable = function($table) { + if ($.fn.DataTable.isDataTable($table)) { + $table.DataTable().destroy(); + $table.unbind(); + } + }, + displayError = function(message) { + $errorMessageContainer.text(message); + $errorMessageContainer.toggleClass('hidden'); + }, + displayAbsoluteGrade = function($cell) { + var $input = $cell.find('input'), + title = $cell.attr('title'); + + if (title !== 'Total' && title !== 'Current grade' && $input.length) { + $input.prop('disabled', false); + $input.val($cell.attr('data-score-earned')); + return; + } + + $cell.text($cell.attr('data-score-absolute')); + }, + displayGrades = function() { + var display = $('#table-data-view-percent').is(':checked') ? displayPercentGrade : displayAbsoluteGrade; + + $('#save-grade-field').hide(); + + $('.data-score-container-class').each(function() { + display($(this)); + }); + }, + displayPercentGrade = function($cell) { + var $input = $cell.find('input'), + title = $cell.attr('title'); + + if (title !== 'Total' && $input.length){ + $input.prop('disabled', true); + $input.val($cell.attr('data-score-percent')); + return; + } + + $cell.text($cell.attr('data-score-percent')); + }, + fetchGrades = function(get_url) { + $.ajax({ + type: 'GET', + url: get_url, + contentType: 'application/json; charset=utf-8', + success: onPageFetched, + failure: function(errMsg) { + isFetchingComplete = true; + isFetchingSuccessful = false; + console.log(errMsg); + displayError('Error has occurred while fetching grades.'); + } + }); + }, + filterGradebook = function() { + var gradingPolicy = $gradingPolicyFilter.val(), + courseSection = $courseSectionFilter.val(), + filterClasses = '.user-data'; + if (gradingPolicy && courseSection) + filterClasses += ',.' + gradingPolicy + '.' + courseSection; + else if (gradingPolicy || courseSection) + filterClasses += ',.' + (gradingPolicy || courseSection); + else + filterClasses = ''; + + dataTable.columns(':not(' + filterClasses + ')').visible(false, false); + dataTable.columns(filterClasses).visible(true, false); + dataTable.columns.adjust().draw(false); + }, + initializeDataTable = function($table, options, studentsDataLength) { + $table.on('length.dt', function(_, _, tableLength) { + // If the provided data is longer than the table length selected + // display the paggination buttons, otherwise hide them. + $(this).parents('.dataTables_wrapper') + .find('.dataTables_paginate') + .toggleClass('hidden', studentsDataLength <= tableLength); + }); + + return $table.DataTable(options); + }, + onFinishedFetchingGrades = function(response) { + isFetchingComplete = true; + isFetchingSuccessful = true; + if (renderAllGradebook) + $filtersWrapper.toggleClass('hidden'); + $gradebookNotification.toggleClass('hidden'); + gradeBookData = gradeBookData.concat(response.results); + gradeBookData = gradeBookData.map(data => { + data.section_breakdown = data.section_breakdown.filter(b => b.chapter_name !== 'holding section') + return data; + }); + renderGradebook(gradeBookData); + }, + onPageFetched = function(response) { + if (response.next) { + gradeBookData = gradeBookData.concat(response.results); + return fetchGrades(response.next); + } + onFinishedFetchingGrades(response); + }, + renderGradingPolicyFilters = function(studentsData) { + _templateLoader('_grading_policies', staticPath, function(template) { + var $tpl = edx.HtmlUtils.template(template)({ + gradingPolicies: Object.keys(studentsData[0].aggregates) + }).toString(); + $('#grading-policy').append($tpl); + $('#grading-policy').append(edx.HtmlUtils.ensureHtml(displayError).toString()); + }, displayError); + }, + renderGradebook = function(studentsData) { + if (renderAllGradebook) + renderGradingPolicyFilters(studentsData); + renderGradebookTable(studentsData); + }, + renderGradebookTable = function(studentsData) { + _templateLoader('_gradebook_table', staticPath, function(template) { + var $tpl = edx.HtmlUtils.template(template)({ + studentsData: studentsData, + strLib: { + userHeading: gettext('Username'), + currentGrade: gettext('Current grade'), + total: gettext('Total') + } + }).toString(); + $gradesTableWrapper.append($tpl); + createMainDataTable(studentsData.length); + ShowBlockIdEventBinder(); + filterGradebook(); + }, displayError); + renderAllGradebook = true; + }, + startFetchingGrades = function() { + $gradebookNotification.toggleClass('hidden'); + fetchGrades('api/grades/v1/gradebook/' + courseID + '/'); + }; + + $gradingPolicyFilter.change(function() { filterGradebook(); }); + $courseSectionFilter.change(function() { filterGradebook(); }); + + function renderModalTemplateData(template) { + var blockID = $(gradeOverrideObject).attr('data-block-id'); + var studentsData = []; + var tpl = edx.HtmlUtils.template(template); + + gradeBookData.map(function(userData){ + var gradeData = userData.section_breakdown.filter(function(sectionData){ + return (sectionData.module_id === blockID); + }); + + if (!_.isEmpty(gradeData)) { + var auto_grade = parseFloat(gradeData[0].auto_grade); + var score_earned = parseFloat(gradeData[0].score_earned); + var score_possible = parseFloat(gradeData[0].score_possible); + var username = userData.username; + userComments[username] = gradeData[0].comment; + + if (! (isNaN(score_earned) || isNaN(score_possible))) { + if (! isNaN(auto_grade)) { + userAutoGrades[username] = auto_grade + '/' + score_possible; + userAdjustedGrades[username] = score_earned + '/' + score_possible; + } + else + userAutoGrades[username] = score_earned + '/' + score_possible; + + studentsData.push(userData); + } + } + }); + + edx.HtmlUtils.setHtml( + $('#grade-override-modal'), + tpl({ + studentsData: studentsData, + strLib: { + heading: gettext("The Assignment name is:"), + publishGrades: gettext("Publish grades"), + noMatch: gettext("No matching records found"), + studentNameHeading: gettext("Student Name"), + commentHeading: gettext("Comment"), + save: gettext("Save"), + cancel: gettext("Cancel") + } + }) + ); + createModalTable(studentsData.length); + fillModalTemplate(); + } + + function fillModalTemplate() { + var $modal = $('.grade-override-modal'); + var $adjustedGradeHeader = $modal.find('#adjusted-grade-header'); + var $autoGradeHeader = $modal.find('#auto-grade-header'); + var $manualGradeVisibilityWrapper = $modal.find('#manual-grade-visibility'); + var $saveGradeOverrideButton = $modal.find('.grade-override-modal-save'); + var $tableWrapper = $modal.find('.grade-override-table-wrapper'); + var assignmentName = $(gradeOverrideObject).attr('data-assignment-name'); + var blockID = $(gradeOverrideObject).attr('data-block-id'); + var dataPublished = $(gradeOverrideObject).attr('data-published') || false; + sectionBlockId = $(gradeOverrideObject).attr('data-section-block-id'); + gradesPublished = JSON.parse(dataPublished); + isManualGrading = JSON.parse($(gradeOverrideObject).attr('data-manual-grading')); + $modal.find('.assignment-name-placeholder').text(assignmentName); + $modal.find('.block-id-placeholder').text(blockID); + if ( _.isEmpty(userAutoGrades) ) { + $tableWrapper.hide(); + $manualGradeVisibilityWrapper.toggle(false); + $modal.find('.grade-override-message').text(gettext('There are no student grades to adjust.')); + $modal.find('.grade-override-message').show(); + $saveGradeOverrideButton.hide(); + } + else { + $adjustedGradeHeader.text(isManualGrading ? 'Manual grade' : 'Adjusted grade'); + $autoGradeHeader.text(isManualGrading ? 'Current grade' : 'Auto grade'); + + $manualGradeVisibilityWrapper.toggle(isManualGrading); + $saveGradeOverrideButton.attr('data-manual-grading', isManualGrading); + $manualGradeVisibilityWrapper.attr('data-visibility', gradesPublished); + $('input[name=grades-published]').prop('checked', gradesPublished); + + $tableWrapper.attr('data-manual-grading', isManualGrading); + $tableWrapper.show(); + $saveGradeOverrideButton.show().prop('disabled', true); + modalDataTable.$('tr').each(function(){ + $(this).attr('data-block-id', blockID); + var $adjustedGradePlaceholder = $(this).find('td.user-adjusted-grade'); + var $autoGradePlaceholder = $(this).find('td.user-auto-grade'); + var $commentPlaceholder = $(this).find('td.user-grade-comment'); + var $commentTextArea = $commentPlaceholder.find('textarea'); + var comment; + var username = $autoGradePlaceholder.attr('data-username'); + + if (username in userAutoGrades) { + $autoGradePlaceholder.text(userAutoGrades[username]); + var autoEarnedGrade = userAutoGrades[username].split('/')[0], + autoPossibleGrade = userAutoGrades[username].split('/')[1]; + $adjustedGradePlaceholder.attr('data-score-earned', autoEarnedGrade); + $adjustedGradePlaceholder.attr('data-score-possible', autoPossibleGrade); + $autoGradePlaceholder.attr('data-sort', autoEarnedGrade); + + if (username in userAdjustedGrades) { + var adjustedGrade = userAdjustedGrades[username].split('/')[0]; + $adjustedGradePlaceholder.attr('data-score-earned', adjustedGrade); + $adjustedGradePlaceholder.attr('data-sort', adjustedGrade); + $adjustedGradePlaceholder.addClass('has-adjusted-score'); + if (autoEarnedGrade != adjustedGrade){ + DisplayGradeComment(username, $commentPlaceholder, $commentTextArea); + } + else + $commentTextArea.prop('disabled', true).val(''); + } + else if (isManualGrading) { + $adjustedGradePlaceholder.attr('data-sort', autoEarnedGrade); + DisplayGradeComment(username, $commentPlaceholder, $commentTextArea); + } + else { + $(this).find('.user-grade-comment textarea').attr('disabled', 'disabled'); + $adjustedGradePlaceholder.attr('data-sort', autoEarnedGrade); + $commentTextArea.prop('disabled', true).val(''); + } + $adjustedGradePlaceholder.find('input').val($adjustedGradePlaceholder.attr('data-score-earned')); + $adjustedGradePlaceholder.find('span').text($adjustedGradePlaceholder.attr('data-score-possible')); + } + else + $(this).hide(); + }); + } + $modal.show(); + } + + /* Autograde override modal window manipulation */ + $(document).on('click', '.grade-override', function() { + gradeOverrideObject = this; + _templateLoader('_gradebook_modal_table', staticPath, renderModalTemplateData, displayError); + }); + + function setUpDataTableSearch($table, $tableEmptyMessage) { + $table.on('search.dt', function () { + if (!$table.DataTable().page.info().recordsDisplay) { + $tableEmptyMessage.show(); + } + else { + $tableEmptyMessage.hide(); + } + }); + } + + $(document).on('click', '.grade-override-modal-close', function(){ + gradebookOverrideModalReset(); + }); + + function gradebookOverrideModalReset() { + var $modal = $('.grade-override-modal'); + adjustedGradesData = {}; + userAdjustedGrades = {}; + userAutoGrades = {}; + userComments = {}; + + $modal.hide(); + $modal.find('.grade-override-table-wrapper').find('tr').show(); + $modal.find('#manual-grade-visibility').hide(); + $modal.find('.grade-override-message').removeClass('error').empty().hide(); + $modal.find('table').find('input').removeClass('score-visited').removeClass('error'); + $modal.find('table').find('textarea').removeClass('score-visited').removeClass('error'); + $modal.find('#modal-table-empty-message').hide(); + destroyDataTable($('#grade-override-modal-table')); + } + + function DisplayGradeComment(username, $commentPlaceholder, $commentTextArea) { + comment = userComments[username]; + $commentPlaceholder.attr('data-comment', comment); + $commentTextArea.prop('disabled', false).val(comment); + } + + /* Block ID modal window manipulation */ + function ShowBlockIdEventBinder() { + $('.eye-icon.block-id-info').on('click', function(e){ + e.stopPropagation(); + $('.block-id-modal').find('.block-id-placeholder').empty(); + $('.block-id-modal').find('.block-id-placeholder').text($(this).data('block-id')); + $('.block-id-modal').find('.display-name-placeholder').text($(this).data('display-name')); + $('.block-id-modal').show(); + }); + } + + function HasUserMadeChanges() { + var areScoresModified = $('.score-visited').length > 0; + var originalGradeVisibility = $('#manual-grade-visibility').attr('data-visibility'); + var currentGradeVisibility = JSON.stringify($('input[name=grades-published]').prop('checked')); + + return areScoresModified || originalGradeVisibility !== currentGradeVisibility; + } + + function ToggleSaveButton(shouldDisable) { + var $modalSaveButton = $('.grade-override-modal').find('.grade-override-modal-save'); + $modalSaveButton.prop('disabled', shouldDisable); + } + + $(document).on('keyup focus', '.user-adjusted-grade input', function(){ + var $row = $(this).parents('tr'), + $cell = $(this).parents('td'), + $commentTextArea = $row.find('.user-grade-comment textarea'), + autoGrade = $row.find('.user-auto-grade').html().split('/')[0], + previousGrade = $cell.attr('data-score-earned'); + adjustedGrade = $(this).val(); + + $cell.attr('data-sort', adjustedGrade); + + if (autoGrade != adjustedGrade || previousGrade != adjustedGrade) + $(this).addClass('score-visited'); + else + $(this).removeClass('score-visited'); + + ToggleSaveButton(!HasUserMadeChanges()); + + if (!isManualGrading) { + if (autoGrade == adjustedGrade) + $commentTextArea.prop('disabled', true).val(''); + else + $commentTextArea.prop('disabled', false); + } + + modalDataTable.rows().invalidate(); + }); + + $(document).on('keyup focus', '.user-grade-comment textarea', function(){ + var originalComment = $(this).parents('td').attr('data-comment'), + changedComment = $(this).val(); + + $(this).toggleClass('score-visited', changedComment != originalComment) + + ToggleSaveButton(!HasUserMadeChanges()); + }); + + $(document).on('change', 'input[name=grades-published]', function() { + ToggleSaveButton(!HasUserMadeChanges()); + }); + + function collectOverrideGradebookData() { + var $modal = $('.grade-override-modal'); + var $table = $modal.find('table').dataTable(); + $table.$('tr').each(function(){ + var $row = $(this); + var $gradeCell = $row.find('.user-adjusted-grade'); + var $grade = $gradeCell.find('input'); + var $commentCell = $row.find('.user-grade-comment'); + var $comment = $commentCell.find('textarea'); + var username = $gradeCell.attr('data-username'); + var autoGrade; + var grade; + var removeAdjustedGrade; + + if ($grade.hasClass('score-visited') || $comment.hasClass('score-visited')) + adjustedGradesData[username] = { + 'block_id' : $row.attr('data-block-id'), + 'max_grade' : $gradeCell.attr('data-score-possible'), + 'state' : { 'username': edxUserInfo.username}, + 'user_id' : $row.attr('data-user-id') + }; + + if (username in adjustedGradesData) { + autoGrade = $row.find('.user-auto-grade').text().split('/')[0]; + grade = $grade.val().trim(); + removeAdjustedGrade = isManualGrading || autoGrade === grade; + + adjustedGradesData[username].grade = grade; + adjustedGradesData[username].state.comment = $comment.val().trim(); + adjustedGradesData[username].remove_adjusted_grade = removeAdjustedGrade; + adjustedGradesData[username].section_block_id = sectionBlockId; + } + }); + } + + $(document).on('click', '.grade-override-modal-save', function() { + var visibilityData = {}; + if (isManualGrading) { + visibilityData = { + 'block_id': $('.block-id-placeholder').html(), + 'visibility': JSON.stringify($('input[name=grades-published]').prop('checked')), + } + } + collectOverrideGradebookData(); + if (Object.keys(adjustedGradesData).length === 0 && !isManualGrading) + return; + var validStatus = ValidateAdjustedGradesData(); + if (validStatus) { + courseXblockUpdater( + courseID, + adjustedGradesData, + visibilityData, + function(data){ + gradebookOverrideModalReset(); + renderAllGradebook = false; + gradeBookData = []; + $gradesTableWrapper.empty(); + startFetchingGrades(); + }, function(data){ + console.log(data); + } + ); + } + }); + + function ValidateAdjustedGradesData() { + var isValid = true; + var $table = $('.grade-override-modal').find('table'); + var $messageField = $('.grade-override-modal').find('.grade-override-message'); + $messageField.empty(); + _.each(adjustedGradesData, function(data, username){ + adjustedGradesData[username].errors = []; + var userAdjustedGradeSelector = '*[data-username="' + username + '"].user-adjusted-grade'; + var $adjustedGradePlaceholder = $table.find(userAdjustedGradeSelector).find('input'); + // Is it a valid number + if (isNaN(data.grade)) { + isValid = false; + $adjustedGradePlaceholder.addClass('error'); + adjustedGradesData[username].errors.push('Adjusted grade must be an integer number'); + } + + // Is it within range + var floatGrade = parseFloat(data.grade); + var errorMessage; + if (floatGrade < 0 || floatGrade > parseFloat(data.max_grade)) { + errorMessage = 'Adjusted grade must be within range [0 - ' + data.max_grade + ']'; + isValid = false; + $adjustedGradePlaceholder.addClass('error'); + adjustedGradesData[username].errors.push(errorMessage); + } + + for (var i = 0; i < adjustedGradesData[username].errors.length; i++) { + $errorMessage = edx.HtmlUtils.joinHtml('Error for user ', username, ': ', adjustedGradesData[username].errors[i], '
').toString(); + $messageField.append($errorMessage); + } + + if (adjustedGradesData[username].errors.length === 0) { + $adjustedGradePlaceholder.removeClass('error'); + delete adjustedGradesData[username].errors; + } + }); + + if (! isValid) { + $messageField.addClass('error'); + $messageField.show(); + } + + return isValid; + } + + $(document).on('change', '#table-data-view-percent', displayGrades); + + $(document).on('change', '#table-data-view-absolute', displayGrades); + + $('.data-score-container-class').each(function(){ + var title = $(this).attr('title'); + if (title !== 'Total' && title !== 'Current grade') + if ($(this).find('input').length) + $(this).find('input').prop('disabled', false); + else + $(this).text($(this).attr('data-score-absolute')); + }); + + $(document).on('change', '#save-grade-field textarea', function(){ + var editor = $('#save-grade-field'), + studentID = editor.attr('data-student-id'), + blockID = editor.attr('data-block-id'), + module_key = studentID + blockID; + + if (!module_list.users[module_key]) + module_list.users[module_key] = { + 'user_id': studentID, + 'grade': parseFloat(editor.attr('data-new-score')).toFixed(2), + 'max_grade': parseFloat(editor.attr('data-score-possible')).toFixed(2), + 'course_id': courseID, + 'block_id': blockID, + 'state': {} + }; + module_list.users[module_key].state.comment = $(this).val(); + }); + + if ($gradebookWrapper.attr('data-number-of-students') > 0) + startFetchingGrades(); +}); \ No newline at end of file diff --git a/lms/templates/courseware/writable_gradebook.html b/lms/templates/courseware/writable_gradebook.html new file mode 100644 index 0000000000..c2c250c9b8 --- /dev/null +++ b/lms/templates/courseware/writable_gradebook.html @@ -0,0 +1,92 @@ +<%page expression_filter="h"/> +<%inherit file="/main.html" /> +<%namespace name='static' file='/static_content.html'/> +<%! +import re +from django.utils.translation import ugettext as _ +from django.core.urlresolvers import reverse +%> + +<%block name="js_extra"> + + + + + + + + + +<%block name="headextra"> + + + <%static:css group='style-course-vendor'/> + <%static:css group='style-course'/> + + +<%include file="/courseware/course_navigation.html" args="active_page=''" /> + +<%def name="get_page_url(page_size)"> + ${page_url}?offset=${page['offset']}&pagesize=${page_size} + + +<%def name="format_class(name)">${re.sub(r'[^a-zA-Z0-9]', '', name)} + +
+
+
+

${_("Gradebook")}

+ + + + %if number_of_students > 0: + + + + +
+ ${_("No matching records found")} +
+
+
+ %else: + ${_("There are no students enrolled in this course.")} + %endif +
+
+
\ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1aded5cca0..49b2a7b1f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2925,6 +2925,31 @@ "whatwg-url": "6.5.0" } }, + "datatables": { + "version": "1.10.18", + "resolved": "https://registry.npmjs.org/datatables/-/datatables-1.10.18.tgz", + "integrity": "sha512-ntatMgS9NN6UMpwbmO+QkYJuKlVeMA2Mi0Gu/QxyIh+dW7ZjLSDhPT2tWlzjpIWEkDYgieDzS9Nu7bdQCW0sbQ==", + "requires": { + "jquery": "2.2.4" + } + }, + "datatables.net": { + "version": "1.10.19", + "resolved": "https://registry.npmjs.org/datatables.net/-/datatables.net-1.10.19.tgz", + "integrity": "sha512-+ljXcI6Pj3PTGy5pesp3E5Dr3x3AV45EZe0o1r0gKENN2gafBKXodVnk2ypKwl2tTmivjxbkiqoWnipTefyBTA==", + "requires": { + "jquery": "2.2.4" + } + }, + "datatables.net-fixedcolumns": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/datatables.net-fixedcolumns/-/datatables.net-fixedcolumns-3.2.6.tgz", + "integrity": "sha512-PtEs2tllcHRVZj7fwmAQBWGJ5URRQZpDG2pJsh5jusvnRje3w1+KueMZm60iCtfOkIlUn+/j2+MghxLx/8yfKQ==", + "requires": { + "datatables.net": "1.10.19", + "jquery": "2.2.4" + } + }, "date-now": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", diff --git a/package.json b/package.json index 36636c41f6..95402ac4e3 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "hls.js": "0.9.0", "imports-loader": "0.7.1", "jquery": "2.2.4", + "datatables": "1.10.18", + "datatables.net-fixedcolumns": "3.2.6", "jquery-migrate": "1.4.1", "jquery.scrollto": "2.1.2", "js-cookie": "2.2.0", diff --git a/pavelib/assets.py b/pavelib/assets.py index 490966e60b..662d25558e 100644 --- a/pavelib/assets.py +++ b/pavelib/assets.py @@ -54,6 +54,9 @@ NPM_INSTALLED_LIBRARIES = [ 'backbone.paginator/lib/backbone.paginator.js', 'backbone/backbone.js', 'bootstrap/dist/js/bootstrap.bundle.js', + 'datatables/media', + 'datatables.net/js/jquery.dataTables.js', + 'datatables.net-fixedcolumns/js/dataTables.fixedColumns.min.js', 'hls.js/dist/hls.js', 'jquery-migrate/dist/jquery-migrate.js', 'jquery.scrollto/jquery.scrollTo.js', @@ -79,6 +82,8 @@ NPM_INSTALLED_DEVELOPER_LIBRARIES = [ NPM_JS_VENDOR_DIRECTORY = path('common/static/common/js/vendor') NPM_CSS_VENDOR_DIRECTORY = path("common/static/common/css/vendor") NPM_CSS_DIRECTORY = path("common/static/common/css") +NPM_MEDIA_DIRECTORY = path("common/static/common/media") +NPM_MEDIA_VENDOR_DIRECTORY = path("common/static/common/media/vendor") # system specific lookup path additions, add sass dirs if one system depends on the sass files for other systems SASS_LOOKUP_DEPENDENCIES = { @@ -601,6 +606,8 @@ def process_npm_assets(): if library.endswith('.css') or library.endswith('.css.map'): vendor_dir = NPM_CSS_VENDOR_DIRECTORY + elif library.endswith('/media'): + vendor_dir = NPM_MEDIA_VENDOR_DIRECTORY else: vendor_dir = NPM_JS_VENDOR_DIRECTORY if os.path.exists(library_path): @@ -631,6 +638,8 @@ def process_npm_assets(): NPM_JS_VENDOR_DIRECTORY.mkdir_p() NPM_CSS_DIRECTORY.mkdir_p() NPM_CSS_VENDOR_DIRECTORY.mkdir_p() + NPM_MEDIA_DIRECTORY.mkdir_p() + NPM_MEDIA_VENDOR_DIRECTORY.mkdir_p() # Copy each file to the vendor directory, overwriting any existing file. print("Copying vendor files into static directory")