diff --git a/app/controllers/admin/minor.py b/app/controllers/admin/minor.py index 291f303e9..80cceccd1 100644 --- a/app/controllers/admin/minor.py +++ b/app/controllers/admin/minor.py @@ -1,11 +1,22 @@ -from flask import render_template, g, abort, request, redirect, url_for, send_file - +from flask import render_template, g, abort, request, redirect, url_for, send_file, jsonify +from app.logic.minor import getMinorProgress from app.models.user import User from app.controllers.admin import admin_bp from app.logic.minor import getMinorInterest, getMinorProgress, toggleMinorInterest, getMinorSpreadsheet, getDeclaredMinorStudents +@admin_bp.route('/profile//cceMinorChart', methods=['GET']) +def cceMinorChart(username): + if not g.current_user.isAdmin: + abort(403) + else: + progressList = getMinorProgress() + turnToChart = [] + for progress in progressList: + turnToChart.append({'name':progress["firstName"] + " " + progress["lastName"], "engagementCount" : progress['engagementCount'], "completeSummer": "Yes" if progress['hasSummer'] == "Completed" else "No", "termDescription": progress['engagementTerm']}) + return jsonify(turnToChart) + @admin_bp.route('/admin/cceMinor', methods=['GET','POST']) def manageMinor(): if not g.current_user.isAdmin: @@ -26,7 +37,8 @@ def manageMinor(): interestedStudentEmailString = ';'.join([student['email'] for student in interestedStudentsList]) sustainedEngagement = getMinorProgress() declaredStudentsList = getDeclaredMinorStudents() - declaredStudentEmailString = ';'.join([student['email'] for student in declaredStudentsList]) + declaredStudentEmailString = ';'.join([student['email'] for student in declaredStudentsList]) + adminUsername = g.current_user.username return render_template('/admin/cceMinor.html', interestedStudentsList = interestedStudentsList, @@ -34,6 +46,7 @@ def manageMinor(): interestedStudentEmailString = interestedStudentEmailString, declaredStudentEmailString = declaredStudentEmailString, sustainedEngagement = sustainedEngagement, + adminUsername = adminUsername, ) @admin_bp.route("/admin/cceMinor/download") diff --git a/app/logic/minor.py b/app/logic/minor.py index 955a124bc..10e0da7d5 100644 --- a/app/logic/minor.py +++ b/app/logic/minor.py @@ -91,14 +91,20 @@ def getMinorProgress(): summerCase = Case(None, [(CCEMinorProposal.proposalType == "Summer Experience", 1)], 0) engagedStudentsWithCount = ( - User.select(User, fn.COUNT(IndividualRequirement.id).alias('engagementCount'), - fn.SUM(summerCase).alias('hasSummer'), - fn.IF(fn.COUNT(CCEMinorProposal.id) > 0, True, False).alias('hasCCEMinorProposal')) - .join(IndividualRequirement, on=(User.username == IndividualRequirement.username)) + User.select( + User, + IndividualRequirement, + Term, + IndividualRequirement.term_id, + fn.COUNT(IndividualRequirement.id).alias('engagementCount'), + fn.SUM(summerCase).alias('hasSummer'), + fn.IF(fn.COUNT(CCEMinorProposal.id) > 0, True, False).alias('hasCCEMinorProposal')) + .join(IndividualRequirement, on=(User.username == IndividualRequirement.username_id)) .join(CertificationRequirement, on=(IndividualRequirement.requirement_id == CertificationRequirement.id)) - .switch(User).join(CCEMinorProposal, JOIN.LEFT_OUTER, on= (User.username == CCEMinorProposal.student)) + .join(Term, on=(IndividualRequirement.term_id == Term.id)) + .switch(User).join(CCEMinorProposal, JOIN.LEFT_OUTER, on= (User.username == CCEMinorProposal.student_id)) .where(CertificationRequirement.certification_id == Certification.CCE) - .group_by(User.firstName, User.lastName, User.username) + .group_by(User.username, IndividualRequirement.term_id, Term.id) .order_by(SQL("engagementCount").desc()) ) engagedStudentsList = [{'firstName': student.firstName, @@ -108,7 +114,8 @@ def getMinorProgress(): 'hasGraduated': student.hasGraduated, 'engagementCount': student.engagementCount - student.hasSummer, 'hasCCEMinorProposal': student.hasCCEMinorProposal, - 'hasSummer': "Completed" if student.hasSummer else "Incomplete"} for student in engagedStudentsWithCount] + 'hasSummer': "Completed" if student.hasSummer else "Incomplete", + 'engagementTerm': student.individualrequirement.term.description} for student in engagedStudentsWithCount] return engagedStudentsList def getMinorSpreadsheet(): diff --git a/app/static/js/minorAdminPage.js b/app/static/js/minorAdminPage.js index ac238e5a3..c6db729f2 100644 --- a/app/static/js/minorAdminPage.js +++ b/app/static/js/minorAdminPage.js @@ -1,5 +1,4 @@ -import searchUser from './searchUser.js' - +import searchUser from './searchUser.js'; $(document).ready(function() { // Load flash message from sessionStorage, if any @@ -61,10 +60,205 @@ $('.remove_minor_candidate').on('click', function() { if (activeTab) { $('#studentTabs button[data-bs-target="#' + activeTab + '"]').tab('show'); } + let barChart = null; + let lineChart = null; + $("#cceMinor").on("click", function(){ + let username = $(this).data("username"); + $.ajax({ + type: 'GET', + url: '/profile/' + username + '/cceMinorChart', + success: function (responses) { + const $barChart = $("#cceChartByEngagement"); + const $lineChart = $("#cceChartByTerm"); + const barCanvas = document.getElementById("cceChartByEngagement"); + const lineCanvas = document.getElementById("cceChartByTerm"); + const SEASONS = ["Spring", "Summer", "Fall"]; + const termToIndex = (term) => { + const [season, yearStr] = term.split(" "); + return Number(yearStr) * 3 + SEASONS.indexOf(season); + }; + const indexToTerm = (idx) => { + const year = Math.floor(idx / 3); + const season = SEASONS[idx % 3]; + return `${season} ${year}`; + }; + + // Build: term { engagement, students[], studentCounts{} } + const byTerm = {}; + + for (const r of responses) { + const term = r.termDescription; + const name = r.name; + const count = Number(r.engagementCount) || 0; + + if (!byTerm[term]) { + byTerm[term] = { engagement: 0, students: [], studentCounts: {} }; + } + + byTerm[term].engagement += count; + byTerm[term].students.push(name); + byTerm[term].studentCounts[name] = (byTerm[term].studentCounts[name] || 0) + count; + } + const existingTerms = Object.keys(byTerm); + + if (!existingTerms.length) { + if (barChart) barChart.destroy(); + if (lineChart) lineChart.destroy(); + $barChart.hide(); + $lineChart.hide(); + return; + } + const indices = existingTerms.map(termToIndex); + const minIdx = Math.min(...indices); + const maxIdx = Math.max(...indices); + const labels = []; + for (let i = minIdx; i <= maxIdx; i++) labels.push(indexToTerm(i)); + + for (const term of labels) { + if (!byTerm[term]) byTerm[term] = { engagement: 0, students: [], studentCounts: {} }; + } + const termEngagements = labels.map((t) => byTerm[t].engagement); + const maxEngagement = Math.max(...termEngagements) + 2; + + const isSummer = (term) => term.startsWith("Summer "); + const barColorsByTerm = labels.map((t) => (isSummer(t) ? "blue" : "green")); + + const formatStudentCounts = (term) => { + const counts = byTerm[term]?.studentCounts || {}; + const entries = Object.entries(counts); + if (!entries.length) return "None"; + return entries.map(([name, cnt]) => `${name} (${cnt})`).join(", "); + }; + + const baseScales = { + y: { + beginAtZero: true, + max: maxEngagement, + ticks: { stepSize: 1 }, + title: { display: true, text: "Engagement Count" } + }, + x: { title: { display: true, text: "Terms" } } + }; + + // Bar chart + if (barChart) barChart.destroy(); + + barChart = new Chart(barCanvas, { + type: "bar", + data: { + labels, + datasets: [ + { + label: "Engagement by Term", + data: termEngagements, + backgroundColor: barColorsByTerm + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: true, + scales: baseScales, + plugins: { + title: { + display: true, + text: "CCE Engagements of Each Term", + font: { size: 18 } + }, + legend: { + display: true, + position: "top", + labels: { + generateLabels: () => [ + { text: "Summer Term", fillStyle: "blue", strokeStyle: "blue", lineWidth: 1 }, + { text: "Non-Summer Term", fillStyle: "green", strokeStyle: "green", lineWidth: 1 } + ] + } + }, + tooltip: { + callbacks: { + label: (context) => { + const term = labels[context.dataIndex]; + return [ + `Engagements: ${context.raw}`, + `Students: ${formatStudentCounts(term)}` + ]; + } + } + } + } + } + }); + + // Line chart + if (lineChart) lineChart.destroy(); + + lineChart = new Chart(lineCanvas, { + type: "line", + data: { + labels, + datasets: [ + { + label: "Engagement by Term", + data: termEngagements, + fill: false + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: true, + scales: baseScales, + plugins: { + title: { + display: true, + text: "CCE Engagements Trends over the Terms", + font: { size: 18 } + }, + tooltip: { + callbacks: { + label: (context) => { + const term = labels[context.dataIndex]; + return [ + `Engagements: ${context.raw}`, + `Students: ${formatStudentCounts(term)}` + ]; + } + } + } + } + } + }); + // Toggle handlers + const showBarChart = () => { + $barChart.show(); + $lineChart.hide(); + setTimeout(() => barChart?.resize(), 0); + }; + const showLineChart = () => { + $barChart.hide(); + $lineChart.show(); + setTimeout(() => lineChart?.resize(), 0); + }; + + $("#chartButton").off("click").on("click", showBarChart); + $("#lineButton").off("click").on("click", showLineChart); + showBarChart(); + } + }); + }); + $("#cceDownload").on("click", function(selected, fileName = "cceMinorChart.png"){ + const element = $(".ccePrint")[0]; + html2canvas(element).then(canvas => { + const downloadLink = document.createElement('a'); + downloadLink.href = canvas.toDataURL(); + downloadLink.download = fileName; + downloadLink.click(); + }) + }) }) - function emailMinorCandidates(studentEmails){ // If there are any students interested or declared, open the mailto link if (studentEmails.length) { @@ -88,7 +282,6 @@ function emailAll(){ emailMinorCandidates(allMinorCandidateEmails); } - function getInterestedStudents() { // get all the checkboxes and return a list of users who's // checkboxes are selected diff --git a/app/templates/admin/cceMinor.html b/app/templates/admin/cceMinor.html index 6e2042d6b..7eac8345c 100644 --- a/app/templates/admin/cceMinor.html +++ b/app/templates/admin/cceMinor.html @@ -3,13 +3,15 @@ {% block scripts %} {{super()}} - - + + + {% block styles %} {{super()}} + {% endblock %} {% endblock %} {% block app_content %} @@ -20,6 +22,32 @@

CCE Minor Progress

Download Report + +
+ + + + + @@ -108,7 +136,6 @@

CCE Minor Candidates

-