From eb034b9e19d48f81bdc3d1bf9e38a61a457987b5 Mon Sep 17 00:00:00 2001 From: Leonardo Mendes <38051204+zMendes@users.noreply.github.com> Date: Fri, 20 Oct 2023 21:25:01 -0300 Subject: [PATCH] Create code view on instructor dashboard (#97) * Initial student-dashboard * Create table with slug + submission * Formatted code block * Updating visuals * Set last submission as default + add points + titles * Hide label chartjs * Fix weekly page url path * Simplifying code * Fix format... --- backend/app/dashboard/static/base.js | 25 +- .../static/dashboard-instructor-student.css | 20 ++ .../static/dashboard-instructor-student.js | 210 ++++++++++++++++ .../dashboard/templates/dashboard/base.html | 1 + .../dashboard/instructor-student.html | 49 ++++ backend/app/dashboard/urls.py | 4 +- backend/app/dashboard/views.py | 224 +++++++++++------- 7 files changed, 446 insertions(+), 87 deletions(-) create mode 100644 backend/app/dashboard/static/dashboard-instructor-student.css create mode 100644 backend/app/dashboard/static/dashboard-instructor-student.js create mode 100644 backend/app/dashboard/templates/dashboard/instructor-student.html diff --git a/backend/app/dashboard/static/base.js b/backend/app/dashboard/static/base.js index 7cefcb3..52d23be 100644 --- a/backend/app/dashboard/static/base.js +++ b/backend/app/dashboard/static/base.js @@ -5,4 +5,27 @@ function courseChanged(select) { baseURL = baseURL.slice(0, -1) baseURL = baseURL.join("/"); window.location = `${baseURL}/${newValue}`; -} \ No newline at end of file +} + +function getClassSelect() { + return document.getElementById("select-class"); + } + + function getCurrentStudents() { + const classSelect = getClassSelect(); + const selectedClass = courseClasses[classSelect.selectedIndex]; + + return selectedClass.students; + } + + function updateStudents() { + let student_datalist = document.getElementById("students"); + student_datalist.innerHTML = ""; + let currentStudents = getCurrentStudents(); + currentStudents.forEach(item => { + let option = document.createElement("option"); + option.value = item; + student_datalist.appendChild(option) + }); + + } diff --git a/backend/app/dashboard/static/dashboard-instructor-student.css b/backend/app/dashboard/static/dashboard-instructor-student.css new file mode 100644 index 0000000..118a261 --- /dev/null +++ b/backend/app/dashboard/static/dashboard-instructor-student.css @@ -0,0 +1,20 @@ +.chart-container { + width: 50rem; + height: fit-content; +} + +.code-snippet { + border: 1px solid #ddd; + margin: 20px; + padding: 10px; + position: relative; +} + +#main { + display: flex; + align-items: flex-start; + justify-content: center; +} +#info { + margin-top: 2rem; +} \ No newline at end of file diff --git a/backend/app/dashboard/static/dashboard-instructor-student.js b/backend/app/dashboard/static/dashboard-instructor-student.js new file mode 100644 index 0000000..efc8db3 --- /dev/null +++ b/backend/app/dashboard/static/dashboard-instructor-student.js @@ -0,0 +1,210 @@ +async function getStudentData() { + clearAll(); + let student = selectStudent.value; + await fetch(`${activeCourse}/${student}`).then(async (response) => { + const data = await response.json(); + document.getElementById("data").style.visibility = "visible"; + createTagChart(data); + }); +} + +function createTagChart(data) { + if (tagChart != null) + tagChart.destroy(); + let labels = Object.keys(data); + let values = Object.values(data); + let count = values.map(item => item.count) + + tagChart = new Chart("tag-chart", { + type: "bar", + data: { + labels: labels, + datasets: [ + { + data: count, + }, + ] + }, + options: { + onClick: function handleBarClick(event, activeElements) { + if (activeElements.length > 0) { + let index = activeElements[0].index; + clearAll(); + createTable(data[labels[index]].data); + } + }, + indexAxis: "y", + responsive: true, + maintainAspectRatio: true, + plugins: { + legend: { + display: false + } + } + } + }); +} + +function createTable(data) { + tableData = data; + let keys = Object.keys(data); + let formatedData = [] + for (let i = 0; i < keys.length; i++) { + formatedData.push([keys[i], data[keys[i]].length]); + } + + let tableDiv = document.getElementById("table"); + document.getElementById("table-div").style.visibility = "visible"; + + + table = new Handsontable(tableDiv, { + data: formatedData, + colHeaders: ["Slug", "Submissions"], + columns: [ + { data: 0 }, + { data: 1 }, + ], + licenseKey: "non-commercial-and-evaluation" + }); + table.addHook('afterSelectionByProp', onRowClicked); +} + +function onRowClicked(row, prop) { + let slug = table.getSourceDataAtRow(row)[0]; + clearInfo(); + createAnswerView(slug, tableData[slug]); +} + +function createAnswerView(slug, data) { + let div = document.getElementById("answers"); + div.innerHTML = ""; + createSubmissionSelect(slug, Object.keys(data), data); + createFileSelect(data); +} + +function createSubmissionSelect(slug, submissions, data) { + let div = document.getElementById("select-submission"); + let name = document.createElement("p"); + name.innerHTML = slug; + + let title = document.createElement("h5"); + title.innerHTML = "Submission"; + div.appendChild(name); + div.appendChild(title); + + submissionSelect = document.createElement('select'); + submissionSelect.className = 'form-select'; + submissionSelect.onchange = function () { + createCode(data[submissionSelect.value], fileSelect.value); + }; + + for (const [key, value] of Object.entries(data.reverse())) { + const option = document.createElement('option'); + option.value = key; + //turn submission number in two digits minimum (formatting reasons) 1 -> 01 2 -> 02 + let submissionNumber = (data.length - parseInt(key)).toLocaleString('en-US', { + minimumIntegerDigits: 2, + useGrouping: false + }); + let optionText = `#${submissionNumber} ${value.date.slice(5, 7)}/${value.date.slice(8, 10)} - ${value.date.slice(11, 16)}` + option.textContent = optionText; + submissionSelect.appendChild(option); + }; + + div.appendChild(submissionSelect); +} + +function createFileSelect(data) { + let div = document.getElementById("select-file"); + div.innerHTML = ""; + let title = document.createElement("h5"); + title.innerHTML = "File"; + div.appendChild(title); + fileSelect = document.createElement('select'); + fileSelect.id = 'select-file'; + fileSelect.className = 'form-select'; + + fileSelect.onchange = function () { + createCode(data[submissionSelect.value], fileSelect.value); + }; + for (key in data[0].log.student_input) { + const option = document.createElement('option'); + option.value = key; + option.textContent = key; + fileSelect.appendChild(option); + } + div.appendChild(fileSelect); + + //create codeBlock with the default submission and file + createCode(data[0], fileSelect.value); +} + + +function createCode(data, fileName) { + let body = document.getElementById("answers"); + body.innerHTML = ""; + + let codeDiv = document.createElement("div"); + let codeSnippet = document.createElement("div"); + codeSnippet.className = "code-snippet"; + + let points = document.createElement("h4"); + points.innerHTML = `Points: ${data.points.toFixed(2)}`; + codeSnippet.appendChild(points); + + let pre = document.createElement("pre"); + let code = document.createElement("code"); + code.className = "language-python"; + code.innerHTML = data.log.student_input[fileName]; + + pre.appendChild(code); + codeSnippet.appendChild(pre); + codeDiv.appendChild(codeSnippet); + body.appendChild(codeDiv); + Prism.highlightElement(code); +} +function clearInfo() { + document.getElementById("select-submission").innerHTML = ""; + document.getElementById("select-file").innerHTML = ""; + document.getElementById("answers").innerHTML = ""; +} + +function clearAll() { + clearInfo(); + document.getElementById("table").innerHTML = ""; + document.getElementById("table-div").style.visibility = "hidden"; +} + +var activeCourse; +var selectStudent; +var tagChart; +var table; +var tableData; +var submissionSelect; +var fileSelect; +var courseClasses; +var selectClass; + +document.addEventListener("DOMContentLoaded", function () { + + document.getElementById("data").style.visibility = "hidden"; + document.getElementById("table-div").style.visibility = "hidden"; + + activeCourse = document.getElementById("select-course").value; + + let activeButton = document.getElementById("student"); + activeButton.className += " active"; + + selectStudent = document.getElementById("select-student"); + selectStudent.onchange = getStudentData; + + selectClass = document.getElementById("select-class"); + selectClass.onchange = updateStudents; + + courseClasses = selectClass.getAttribute("data-classes"); + courseClasses = courseClasses.replace(/'/g, '"'); + courseClasses = JSON.parse(courseClasses); + courseClasses.forEach((courseClass) => { + courseClass.students = new Set(courseClass.students); + }); +}); diff --git a/backend/app/dashboard/templates/dashboard/base.html b/backend/app/dashboard/templates/dashboard/base.html index 085638d..6f28c85 100644 --- a/backend/app/dashboard/templates/dashboard/base.html +++ b/backend/app/dashboard/templates/dashboard/base.html @@ -40,6 +40,7 @@

Dashboard

Semester Weekly + Code {% if course_classes|length == 0 %} diff --git a/backend/app/dashboard/templates/dashboard/instructor-student.html b/backend/app/dashboard/templates/dashboard/instructor-student.html new file mode 100644 index 0000000..a8e6a30 --- /dev/null +++ b/backend/app/dashboard/templates/dashboard/instructor-student.html @@ -0,0 +1,49 @@ +{% extends "dashboard/base.html" %} +{% load static %} + + +{% block head-content %} + + + + + + + + + +{% endblock %} + +{% block specific-header %} +
+ Student + + {% for student in students %} + + {% endfor %} + +
+{% endblock %} + +{% block data %} +
+
+

Code exercises by tag

+ + +
+
+

Exercise table

+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/backend/app/dashboard/urls.py b/backend/app/dashboard/urls.py index 954e7ba..0a6f788 100644 --- a/backend/app/dashboard/urls.py +++ b/backend/app/dashboard/urls.py @@ -8,6 +8,8 @@ path("instructor/", views.instructor_courses, name='instructor-dashboard'), path("instructor//", views.instructor_courses, name='instructor-dashboard'), path("instructor/weekly////", views.student_weekly_data), - path("instructor/weekly///", views.weekly_exercises) + path("instructor/weekly///", views.weekly_exercises), + path("instructor/student///", views.student_code_data), + ] diff --git a/backend/app/dashboard/views.py b/backend/app/dashboard/views.py index 19b269d..fb3dda4 100644 --- a/backend/app/dashboard/views.py +++ b/backend/app/dashboard/views.py @@ -47,8 +47,131 @@ def instructor_courses(request, course_name=None, content_type=None): course_name = Course.objects.first().name if content_type == 'weekly': return weekly_progress(request, course_name) + elif content_type == 'student': + return code_info(request, course_name) return students_progress(request, course_name) +@staff_member_required +@api_view() +@login_required +def student_weekly_data(request, course_name, class_name, user_nickname, week): + course_name = unquote_plus(course_name) + course = get_object_or_404(Course, name=course_name) + exercises = Exercise.objects.filter(course=course) + student = get_object_or_404(Student, username=user_nickname) + + week_start = datetime.fromisoformat(week) + week_end = week_start + timedelta(days=6) + + exercises = TelemetryData.objects.filter( + exercise__in=exercises, author=student, + submission_date__gte=week_start, submission_date__lte=week_end + ).prefetch_related('exercise__tags') + + metrics = { + 'total': len(exercises), + 'exercises': {}, + 'choice': {}, + 'tags': {}, + } + aggr_points = 0 + for exercise in exercises: + exercise_slug = exercise.exercise.slug + exercise_points = round(exercise.points, 2) + exercise_tags = list( + exercise.exercise.tags.all().values_list('name', flat=True)) + isChoice = 'choice-exercise' in exercise_tags + aggr_points += exercise_points + if exercise_slug not in metrics['exercises']: + metrics['exercises'][exercise_slug] = { + 'slug': exercise_slug, + 'best_score': exercise_points, + 'submissions': 1, + } + metrics['exercises'][exercise_slug]['type'] = 'choice' if 'choice-exercise' in exercise_tags else 'code' + for tag in exercise_tags: + if isChoice and tag != 'choice-exercise': + metrics['choice'].setdefault(tag, { 'wrong': 0, 'correct': 0}) + metrics['choice'][tag]['wrong'] += (not exercise_points) + metrics['choice'][tag]['correct'] += exercise_points + metrics['tags'].setdefault(tag, 0) + metrics['tags'][tag] += 1 + else: + metrics['exercises'][exercise_slug]['submissions'] += 1 + if exercise_points > metrics['exercises'][exercise_slug]['best_score']: + metrics['exercises'][exercise_slug]['best_score'] = exercise_points + + metrics['average_points'] = aggr_points / \ + metrics['total'] if metrics['total'] != 0 else 0 + + return Response(metrics) + +@staff_member_required +@api_view() +@login_required +def weekly_exercises(request, course_name, class_name, week): + + course_name = unquote_plus(course_name) + course = get_object_or_404(Course, name=course_name) + class_name = unquote_plus(class_name) + course_class = get_object_or_404(CourseClass, name=class_name) + exercises = Exercise.objects.filter(course=course) + + week_start = datetime.fromisoformat(week) + week_end = week_start + timedelta(days=6) + + user_exercise_counts = Student.objects.filter(courseclass=course_class).annotate( + exercise_count=Count('telemetrydata', + filter=Q(telemetrydata__submission_date__gte=week_start, + telemetrydata__submission_date__lte=week_end, telemetrydata__exercise__in=exercises)) + ).values('username', 'exercise_count') + hist = {} + granularity = 5 + # converting to histogram + for user in user_exercise_counts: + exercise_count = int( + math.ceil(user['exercise_count'] / granularity)) * granularity + if exercise_count >= 50: + exercise_count = '>50' + hist.setdefault(exercise_count, 0) + hist[exercise_count] += 1 + + return Response(hist) + +@staff_member_required +@api_view() +@login_required +def student_code_data(request, course_name, user_nickname): + course_name = unquote_plus(course_name) + course = get_object_or_404(Course, name=course_name) + user_nickname = unquote_plus(user_nickname) + student = get_object_or_404(Student, username=user_nickname) + + exercises = Exercise.objects.filter(course=course) + telemetry_data = TelemetryData.objects.filter( + exercise__in=exercises, author=student, + ).prefetch_related('exercise__tags') + + response_obj = {} + for telemetry in telemetry_data[:]: + slug = telemetry.exercise.slug + exercise_tags = list( + telemetry.exercise.tags.all().values_list('name', flat=True)) + if 'Código' in exercise_tags: + exercise_tags.remove('Código') + for tag in exercise_tags: + response_obj.setdefault(tag, {'count':0, 'data':{}}) + response_obj[tag]['data'].setdefault(slug, []) + response_obj[tag]['count'] +=1 + ex_data = { + 'date': telemetry.submission_date, + 'log': telemetry.log, + 'last': telemetry.last, + 'points': round(telemetry.points, 2) + } + response_obj[tag]['data'][slug].append(ex_data) + + return Response(response_obj) def students_progress(request, course_name): @@ -95,7 +218,6 @@ def students_progress(request, course_name): }) - def weekly_progress(request, course_name): course_name = unquote_plus(course_name) course = get_object_or_404(Course, name=course_name) @@ -153,91 +275,23 @@ def generate_weeks(start_date, end_date): 'activeCourse': course_name }) - -@staff_member_required -@api_view() -@login_required -def student_weekly_data(request, course_name, class_name, user_nickname, week): - course_name = unquote_plus(course_name) - course = get_object_or_404(Course, name=course_name) - exercises = Exercise.objects.filter(course=course) - student = get_object_or_404(Student, username=user_nickname) - - week_start = datetime.fromisoformat(week) - week_end = week_start + timedelta(days=6) - - exercises = TelemetryData.objects.filter( - exercise__in=exercises, author=student, - submission_date__gte=week_start, submission_date__lte=week_end - ).prefetch_related('exercise__tags') - - metrics = { - 'total': len(exercises), - 'exercises': {}, - 'choice': {}, - 'tags': {}, - } - aggr_points = 0 - for exercise in exercises: - exercise_slug = exercise.exercise.slug - exercise_points = round(exercise.points, 2) - exercise_tags = list( - exercise.exercise.tags.all().values_list('name', flat=True)) - isChoice = 'choice-exercise' in exercise_tags - aggr_points += exercise_points - if exercise_slug not in metrics['exercises']: - metrics['exercises'][exercise_slug] = { - 'slug': exercise_slug, - 'best_score': exercise_points, - 'submissions': 1, - } - metrics['exercises'][exercise_slug]['type'] = 'choice' if 'choice-exercise' in exercise_tags else 'code' - for tag in exercise_tags: - if isChoice and tag != 'choice-exercise': - metrics['choice'].setdefault(tag, { 'wrong': 0, 'correct': 0}) - metrics['choice'][tag]['wrong'] += (not exercise_points) - metrics['choice'][tag]['correct'] += exercise_points - metrics['tags'].setdefault(tag, 0) - metrics['tags'][tag] += 1 - else: - metrics['exercises'][exercise_slug]['submissions'] += 1 - if exercise_points > metrics['exercises'][exercise_slug]['best_score']: - metrics['exercises'][exercise_slug]['best_score'] = exercise_points - - metrics['average_points'] = aggr_points / \ - metrics['total'] if metrics['total'] != 0 else 0 - - return Response(metrics) - - -@staff_member_required -@api_view() -@login_required -def weekly_exercises(request, course_name, class_name, week): +def code_info(request, course_name): course_name = unquote_plus(course_name) course = get_object_or_404(Course, name=course_name) - class_name = unquote_plus(class_name) - course_class = get_object_or_404(CourseClass, name=class_name) - exercises = Exercise.objects.filter(course=course) - - week_start = datetime.fromisoformat(week) - week_end = week_start + timedelta(days=6) - - user_exercise_counts = Student.objects.filter(courseclass=course_class).annotate( - exercise_count=Count('telemetrydata', - filter=Q(telemetrydata__submission_date__gte=week_start, - telemetrydata__submission_date__lte=week_end, telemetrydata__exercise__in=exercises)) - ).values('username', 'exercise_count') - hist = {} - granularity = 5 - # converting to histogram - for user in user_exercise_counts: - exercise_count = int( - math.ceil(user['exercise_count'] / granularity)) * granularity - if exercise_count >= 50: - exercise_count = '>50' - hist.setdefault(exercise_count, 0) - hist[exercise_count] += 1 + course_classes = course.courseclass_set.all().prefetch_related('students') + course_classes_list = [ + {'name': course_class.name, 'students': list( + course_class.students.values_list('username', flat=True))} + for course_class in course_classes + ] + students = Student.objects.all() + courses = Course.objects.all() - return Response(hist) + return render(request, 'dashboard/instructor-student.html', + { + 'courses': courses, + 'course_classes': course_classes_list, + 'activeCourse': course_name, + 'students': students, + }) \ No newline at end of file