diff --git a/assignments/static/assignments/detail.js b/assignments/static/assignments/detail.js index e264624..cf46d04 100644 --- a/assignments/static/assignments/detail.js +++ b/assignments/static/assignments/detail.js @@ -176,8 +176,10 @@ async function getVersioningPages() { return versioningPages; } -const courseId = JSON.parse(document.getElementById('course_id').textContent); -const assignmentId = JSON.parse(document.getElementById('assignment_id').textContent); +const courseIdElement = document.getElementById('course_id'); +const assignmentIdElement = document.getElementById('assignment_id'); +const courseId = courseIdElement ? JSON.parse(courseIdElement.textContent) : null; +const assignmentId = assignmentIdElement ? JSON.parse(assignmentIdElement.textContent) : null; // function to set grade inputs step size from checked_pages async function initializeCheckedPages() { @@ -991,8 +993,10 @@ function syncCarouselSlide(event) { const cardCarousels = document.querySelectorAll('.card .carousel'); // get the corresponding set of bootstrap carousel instances const bsCarousels = [] -for (const carousel of cardCarousels) { - bsCarousels.push(bootstrap.Carousel.getOrCreateInstance(carousel)); +if (window.bootstrap && bootstrap.Carousel) { + for (const carousel of cardCarousels) { + bsCarousels.push(bootstrap.Carousel.getOrCreateInstance(carousel)); + } } const btnCarouselPrev = document.querySelectorAll('.card .carousel-control-prev'); @@ -1164,215 +1168,361 @@ function deleteAllSubs(event) { } async function fetchGrades() { + if (!assignmentId) { + throw new Error("Missing assignment_id for grade fetch."); + } const url = `/assignments/${assignmentId}/grades/`; const response = await fetch(url); - console.log(response); + if (!response.ok) { + throw new Error(`Failed to fetch grades: ${response.status}`); + } const data = await response.json(); - console.log("data", data); - console.log(data["grades"]); return data["grades"]; } var chart = document.getElementById('myChart') if (chart) { - const groupButtonContainer = document.createElement('div'); - groupButtonContainer.classList.add('d-flex', 'justify-content-end', 'my-2'); - // add the button group to the parent of the chart - chart.parentElement.append(groupButtonContainer); - // or with innerHTML - groupButtonContainer.innerHTML = ` -
-
- - - - - - -
-
- `; - const groupByDefaultButton = document.getElementById('group-by-default'); - const groupByVersionButton = document.getElementById('group-by-version'); - const groupByQuestionButton = document.getElementById('group-by-question'); - - var ctx = chart.getContext('2d'); - // await the maxScore from the server - const maxScore = JSON.parse(document.getElementById('max_score').textContent); - const all_grades_old = JSON.parse(document.getElementById('all_grades').textContent); - console.log(all_grades_old); - // find the min and max of the grades from all_grades - // const values = Object.values(all_grades); - - let fetched_grades = []; - try { - fetched_grades = await fetchGrades(); - } catch (error) { - console.log(error); - } + (async function initializeAssignmentChart() { + if (chart.dataset.chartInit === "1") { + return; + } + chart.dataset.chartInit = "1"; - // get the "grade" entry of each object and store them in an array - const all_grades = fetched_grades.map(obj => obj.grade).filter(grade => grade !== "" && grade !== null); - - // set the min value of the x-axis to 0 and the max value to the max possible grade of the assignment - const minm = 0; - const maxm = maxScore; - var histGenerator = d3.bin() - .domain([minm, maxm]) // TODO: get the min and max of the grades - .thresholds(39); // number of thresholds; this will create 19+1 bins - - var bins = histGenerator(all_grades); - console.log(bins); - const x_axis = [] - const y_axis = [] - for (var i = 0; i < bins.length; i++) { - x_axis.push(bins[i].x0) - y_axis.push(bins[i].length) - } - const data = { - labels: x_axis, - datasets: [{ - label: 'Submission Grades', - data: y_axis, - backgroundColor: [ - 'rgba(255, 99, 132, 0.2)', - ], - borderColor: [ - 'rgba(255, 99, 132, 1)' - ], - borderWidth: 1, - }] - }; + const chartContainer = chart.parentElement; + // Remove legacy dynamically injected chart control rows from older JS versions. + const legacyControlRows = document.querySelectorAll( + '.d-flex.justify-content-end.my-2, .d-flex.justify-content-end.mb-2' + ); + legacyControlRows.forEach((row) => { + if (row.id === 'chart-controls') { + return; + } + if (row.querySelector('input[name="chart-type"]')) { + row.remove(); + } + }); + + const groupButtonContainer = chartContainer.querySelector('#chart-controls'); + if (!groupButtonContainer) { + console.warn('Missing #chart-controls in template; skipping chart control initialization.'); + return; + } + + const groupByDefaultButton = groupButtonContainer.querySelector('#group-by-default'); + const groupByVersionButton = groupButtonContainer.querySelector('#group-by-version'); + const groupByQuestionButton = groupButtonContainer.querySelector('#group-by-question'); + const groupBySectionButton = groupButtonContainer.querySelector('#group-by-section'); + if (!groupByDefaultButton || !groupByVersionButton || !groupByQuestionButton || !groupBySectionButton) { + console.warn('Missing one or more chart mode buttons; skipping chart mode initialization.'); + return; + } - var myChart = new Chart(ctx); - - function updateChartGroupDefault() { - myChart.config.type = 'bar'; - myChart.config.data = data; - myChart.config.options.scales = { - y: { - beginAtZero: true, - ticks: { - autoSkip: true, - maxTicksLimit: 10 + var ctx = chart.getContext('2d'); + const fallbackAllGrades = JSON.parse( + document.getElementById('all_grades').textContent + ); + const maxScore = Number( + JSON.parse(document.getElementById('max_score').textContent) + ); + + let fetched_grades = []; + try { + fetched_grades = await fetchGrades(); + } catch (error) { + console.log(error); + } + + if (!Array.isArray(fetched_grades) || fetched_grades.length === 0) { + fetched_grades = fallbackAllGrades.map((grade, idx) => ({ + submission_id: `fallback-${idx}`, + version: '', + grade: grade, + section_name: 'Unassigned', + })); + } + + function toNumericGrade(grade) { + if (grade === "" || grade === null || grade === undefined) { + return null; + } + const numeric = Number(grade); + if (Number.isNaN(numeric)) { + return null; + } + return numeric; + } + + function sectionNameFromObject(obj) { + const sectionName = (obj.section_name || "").toString().trim(); + return sectionName || "Unassigned"; + } + + function getGradeValues(records) { + return records + .map((obj) => toNumericGrade(obj.grade)) + .filter((grade) => grade !== null); + } + + function groupByVersion(records) { + return records.reduce((acc, obj) => { + const grade = toNumericGrade(obj.grade); + if (grade === null) { + return acc; } - }, - x: { - ticks: { - min: 0, - max: maxScore, - stepSize: 1, - maxTicksLimit: 10, + const version = obj.version || ''; + if (!acc[version]) { + acc[version] = []; + } + acc[version].push(grade); + return acc; + }, {}); + } + + function groupByQuestion(records) { + return records.reduce((acc, obj) => { + for (const [key, value] of Object.entries(obj)) { + if (!key.startsWith('question_') || !key.endsWith('_grade')) { + continue; + } + const question = key.split('_')[1]; + if (!acc[question]) { + acc[question] = []; + } + const numericValue = toNumericGrade(value); + if (numericValue !== null) { + acc[question].push(numericValue); + } + } + return acc; + }, {}); + } + + function groupBySection(records) { + return records.reduce((acc, obj) => { + const grade = toNumericGrade(obj.grade); + if (grade === null) { + return acc; + } + const sectionName = sectionNameFromObject(obj); + if (!acc[sectionName]) { + acc[sectionName] = []; } + acc[sectionName].push(grade); + return acc; + }, {}); + } + + function updateGroupedButtonsDisabledState(records) { + const groupedVersion = groupByVersion(records); + const groupedQuestion = groupByQuestion(records); + const groupedSection = groupBySection(records); + groupByVersionButton.disabled = Object.keys(groupedVersion).length <= 1; + groupByQuestionButton.disabled = Object.keys(groupedQuestion).length <= 1; + groupBySectionButton.disabled = Object.keys(groupedSection).length === 0; + return { groupedVersion, groupedQuestion, groupedSection }; + } + + var histGenerator = d3.bin() + .domain([0, maxScore]) + .thresholds(39); + + var myChart = new Chart(ctx, { + type: 'bar', + data: { + labels: [], + datasets: [], + }, + options: { + responsive: true, + maintainAspectRatio: true, } - }; - myChart.update(); - } - updateChartGroupDefault(); - groupByDefaultButton.addEventListener('click', updateChartGroupDefault); + }); - - // add event listener for the group-by-version button - // when the button is clicked, group the grades by version - if (groupByVersionButton) { - // group by version - const grouped_version = fetched_grades.reduce((acc, obj) => { - const version = obj.version; - if (!acc[version]) { - acc[version] = []; + function updateChartGroupDefault(records) { + const grades = getGradeValues(records); + const bins = histGenerator(grades); + const x_axis = []; + const y_axis = []; + for (let i = 0; i < bins.length; i++) { + x_axis.push(bins[i].x0); + y_axis.push(bins[i].length); } - // skip empty grades - if (obj.grade !== "" && obj.grade !== null) - acc[version].push(obj.grade); - return acc; - }, {}); - console.log(grouped_version); - // if there is at most one version, disable the button - if (Object.keys(grouped_version).length <= 1) { - groupByVersionButton.disabled = true; + myChart.config.type = 'bar'; + myChart.config.data = { + labels: x_axis, + datasets: [{ + label: 'Submission Grades', + data: y_axis, + backgroundColor: ['rgba(255, 99, 132, 0.2)'], + borderColor: ['rgba(255, 99, 132, 1)'], + borderWidth: 1, + }] + }; + myChart.config.options = { + responsive: true, + maintainAspectRatio: true, + scales: { + y: { + beginAtZero: true, + ticks: { + autoSkip: true, + maxTicksLimit: 10 + } + }, + x: { + ticks: { + min: 0, + max: maxScore, + stepSize: 1, + maxTicksLimit: 10, + } + } + } + }; + myChart.update(); } - function updateChartGroupVersions() { + + function updateChartGroupVersions(records, groupedVersion) { + const versionKeys = Object.keys(groupedVersion).sort((a, b) => { + if (a === '' && b !== '') { + return -1; + } + if (b === '' && a !== '') { + return 1; + } + const aNumber = Number(a); + const bNumber = Number(b); + if (!Number.isNaN(aNumber) && !Number.isNaN(bNumber)) { + return aNumber - bNumber; + } + return a.localeCompare(b); + }); + const grades = versionKeys.map((versionKey) => groupedVersion[versionKey]); myChart.config.type = 'violin'; myChart.config.data = { - labels: Object.keys(grouped_version).map(version => { - version = version === '' ? 'Outliers' : `Version ${version}` - return version; + labels: versionKeys.map((version) => { + if (version === '') { + return 'Outliers'; + } + return `Version ${version}`; + }), + datasets: [{ + label: 'Versions', + data: grades, + backgroundColor: 'rgba(255, 99, 132, 0.2)', + borderColor: 'rgba(255, 99, 132, 1)', + borderWidth: 1, + outlierColor: '#999999', + padding: 10, + itemRadius: 0, + }] + }; + myChart.config.options = { + responsive: true, + maintainAspectRatio: true, + scales: { + y: { + min: 0, + max: maxScore, + } } - ), - datasets: [] }; - const grades = Object.values(grouped_version); - - myChart.config.data.datasets.push({ - label: `Versions`, - data: grades, - backgroundColor: 'rgba(255, 99, 132, 0.2)', - borderColor: 'rgba(255, 99, 132, 1)', - borderWidth: 1, - outlierColor: '#999999', - padding: 10, - itemRadius: 0, - }); - - // // change the y axis limit to the min and max of the grades - myChart.config.options.scales.y.min = 0; - myChart.config.options.scales.y.max = Math.max(...all_grades); myChart.update(); - console.log(myChart.config.data); } - groupByVersionButton.addEventListener('click', updateChartGroupVersions); - } - - // add event listener for the group-by-question button - // when the button is clicked, group the grades by question - if (groupByQuestionButton) { - const grouped_question = fetched_grades.reduce((acc, obj) => { - // fields `question_{i}_grade` are the grades for each question - for (const [key, value] of Object.entries(obj)) { - if (key.startsWith('question_')) { - const question = key.split('_')[1]; - if (!acc[question]) { - acc[question] = []; + function updateChartGroupQuestions(records, groupedQuestion) { + const questionKeys = Object.keys(groupedQuestion).sort( + (a, b) => Number(a) - Number(b) + ); + const grades = questionKeys.map((questionKey) => groupedQuestion[questionKey]); + myChart.config.type = 'violin'; + myChart.config.data = { + labels: questionKeys.map((question) => `Question ${question}`), + datasets: [{ + label: 'Questions', + data: grades, + backgroundColor: 'rgba(255, 99, 132, 0.2)', + borderColor: 'rgba(255, 99, 132, 1)', + borderWidth: 1, + outlierColor: '#999999', + padding: 10, + itemRadius: 0, + }] + }; + myChart.config.options = { + responsive: true, + maintainAspectRatio: true, + scales: { + y: { + min: 0, + max: (grades.flat().length === 0) ? 1 : Math.max(...grades.flat()), } - // skip empty grades - if (value !== "" && value !== null) - acc[question].push(value); - } - } - return acc; - }, {}); - console.log(grouped_question); - if (Object.keys(grouped_question).length <= 1) { - groupByQuestionButton.disabled = true; + }; + myChart.update(); } - function updateChartGroupQuestions() { + + function updateChartGroupSections(records, groupedSection) { + const sectionKeys = Object.keys(groupedSection).sort( + (a, b) => a.localeCompare(b) + ); + const grades = sectionKeys.map((sectionKey) => groupedSection[sectionKey]); myChart.config.type = 'violin'; myChart.config.data = { - labels: Object.keys(grouped_question).map(question => `Question ${question}`), - datasets: [] + labels: sectionKeys, + datasets: [{ + label: 'Sections', + data: grades, + backgroundColor: 'rgba(255, 99, 132, 0.2)', + borderColor: 'rgba(255, 99, 132, 1)', + borderWidth: 1, + outlierColor: '#999999', + padding: 10, + itemRadius: 0, + }] + }; + myChart.config.options = { + responsive: true, + maintainAspectRatio: true, + scales: { + y: { + min: 0, + max: (grades.flat().length === 0) ? 1 : Math.max(...grades.flat()), + } + } }; - const grades = Object.values(grouped_question); - myChart.config.data.datasets.push({ - label: `Questions`, - data: grades, - backgroundColor: 'rgba(255, 99, 132, 0.2)', - borderColor: 'rgba(255, 99, 132, 1)', - borderWidth: 1, - outlierColor: '#999999', - padding: 10, - itemRadius: 0, - }); - // change the y axis limit to the min and max of the grades - myChart.config.options.scales.y.min = 0; - myChart.config.options.scales.y.max = (grades.flat().length === 0) ? 1 : Math.max(...grades.flat()); myChart.update(); - console.log(myChart.config.data); } - groupByQuestionButton.addEventListener('click', updateChartGroupQuestions); - } + function renderActiveChart() { + const selectedRecords = fetched_grades; + const { groupedVersion, groupedQuestion, groupedSection } = ( + updateGroupedButtonsDisabledState(selectedRecords) + ); + + if (groupByVersionButton.checked && !groupByVersionButton.disabled) { + updateChartGroupVersions(selectedRecords, groupedVersion); + return; + } + if (groupByQuestionButton.checked && !groupByQuestionButton.disabled) { + updateChartGroupQuestions(selectedRecords, groupedQuestion); + return; + } + if (groupBySectionButton.checked && !groupBySectionButton.disabled) { + updateChartGroupSections(selectedRecords, groupedSection); + return; + } + groupByDefaultButton.checked = true; + updateChartGroupDefault(selectedRecords); + } + + groupByDefaultButton.addEventListener('click', renderActiveChart); + groupByVersionButton.addEventListener('click', renderActiveChart); + groupByQuestionButton.addEventListener('click', renderActiveChart); + groupBySectionButton.addEventListener('click', renderActiveChart); + + renderActiveChart(); + })(); } // add event listener for the .search-bar__button diff --git a/assignments/templates/assignments/detail.html b/assignments/templates/assignments/detail.html index e0fbb0d..983eeac 100644 --- a/assignments/templates/assignments/detail.html +++ b/assignments/templates/assignments/detail.html @@ -17,7 +17,7 @@ {{ assignment.pk|json_script:"assignment_id"}} {{ course_pk|json_script:"course_id"}} - + {% endblock scripts %} @@ -32,14 +32,29 @@ @@ -278,7 +293,7 @@