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
+
+
+
+{% endblock %}
+
+{% block data %}
+
+
+
Code exercises by tag
+
+
+
+
+
+{% 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