diff --git a/add_more_test_data.py b/add_more_test_data.py index 45ecab1..2934d0d 100644 --- a/add_more_test_data.py +++ b/add_more_test_data.py @@ -167,6 +167,8 @@ def create_answers_for_student(submission): homework=homework, student=user, defaults={"enrollment": enrollment}, + time_spent_lectures=random.randint(0, 10), + time_spent_homework=random.randint(0, 10), ) if created: diff --git a/courses/migrations/0017_alter_projectsubmission_learning_in_public_links_and_more.py b/courses/migrations/0017_alter_projectsubmission_learning_in_public_links_and_more.py new file mode 100644 index 0000000..669806c --- /dev/null +++ b/courses/migrations/0017_alter_projectsubmission_learning_in_public_links_and_more.py @@ -0,0 +1,63 @@ +# Generated by Django 4.2.14 on 2024-10-10 12:48 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('courses', '0016_enrollment_about_me_enrollment_github_url_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='projectsubmission', + name='learning_in_public_links', + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='submission', + name='learning_in_public_links', + field=models.JSONField(blank=True, help_text='Links where students talk about the course', null=True), + ), + migrations.CreateModel( + name='HomeworkStatistics', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('total_submissions', models.IntegerField(default=0)), + ('min_questions_score', models.IntegerField(blank=True, null=True)), + ('max_questions_score', models.IntegerField(blank=True, null=True)), + ('avg_questions_score', models.FloatField(blank=True, null=True)), + ('median_questions_score', models.FloatField(blank=True, null=True)), + ('q1_questions_score', models.FloatField(blank=True, null=True)), + ('q3_questions_score', models.FloatField(blank=True, null=True)), + ('min_total_score', models.IntegerField(blank=True, null=True)), + ('max_total_score', models.IntegerField(blank=True, null=True)), + ('avg_total_score', models.FloatField(blank=True, null=True)), + ('median_total_score', models.FloatField(blank=True, null=True)), + ('q1_total_score', models.FloatField(blank=True, null=True)), + ('q3_total_score', models.FloatField(blank=True, null=True)), + ('min_learning_in_public_score', models.IntegerField(blank=True, null=True)), + ('max_learning_in_public_score', models.IntegerField(blank=True, null=True)), + ('avg_learning_in_public_score', models.FloatField(blank=True, null=True)), + ('median_learning_in_public_score', models.FloatField(blank=True, null=True)), + ('q1_learning_in_public_score', models.FloatField(blank=True, null=True)), + ('q3_learning_in_public_score', models.FloatField(blank=True, null=True)), + ('min_time_spent_lectures', models.FloatField(blank=True, null=True)), + ('max_time_spent_lectures', models.FloatField(blank=True, null=True)), + ('avg_time_spent_lectures', models.FloatField(blank=True, null=True)), + ('median_time_spent_lectures', models.FloatField(blank=True, null=True)), + ('q1_time_spent_lectures', models.FloatField(blank=True, null=True)), + ('q3_time_spent_lectures', models.FloatField(blank=True, null=True)), + ('min_time_spent_homework', models.FloatField(blank=True, null=True)), + ('max_time_spent_homework', models.FloatField(blank=True, null=True)), + ('avg_time_spent_homework', models.FloatField(blank=True, null=True)), + ('median_time_spent_homework', models.FloatField(blank=True, null=True)), + ('q1_time_spent_homework', models.FloatField(blank=True, null=True)), + ('q3_time_spent_homework', models.FloatField(blank=True, null=True)), + ('last_calculated', models.DateTimeField(auto_now=True)), + ('homework', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='statistics', to='courses.homework')), + ], + ), + ] diff --git a/courses/models/homework.py b/courses/models/homework.py index 6204831..0fec3b9 100644 --- a/courses/models/homework.py +++ b/courses/models/homework.py @@ -155,7 +155,10 @@ class Submission(models.Model): homework_link = models.URLField( blank=True, null=True, - validators=[URLValidator(schemes=["http", "https", "git"]), validate_url_200], + validators=[ + URLValidator(schemes=["http", "https", "git"]), + validate_url_200, + ], ) learning_in_public_links = models.JSONField( blank=True, @@ -199,3 +202,120 @@ class Answer(models.Model): def __str__(self): return f"Answer id={self.id} for {self.question}" + + +class HomeworkStatistics(models.Model): + homework = models.OneToOneField( + Homework, on_delete=models.CASCADE, related_name="statistics" + ) + + total_submissions = models.IntegerField(default=0) + + # Fields for questions_score + min_questions_score = models.IntegerField(null=True, blank=True) + max_questions_score = models.IntegerField(null=True, blank=True) + avg_questions_score = models.FloatField(null=True, blank=True) + median_questions_score = models.FloatField(null=True, blank=True) + q1_questions_score = models.FloatField(null=True, blank=True) + q3_questions_score = models.FloatField(null=True, blank=True) + + # Fields for total_score + min_total_score = models.IntegerField(null=True, blank=True) + max_total_score = models.IntegerField(null=True, blank=True) + avg_total_score = models.FloatField(null=True, blank=True) + median_total_score = models.FloatField(null=True, blank=True) + q1_total_score = models.FloatField(null=True, blank=True) + q3_total_score = models.FloatField(null=True, blank=True) + + # Fields for learning_in_public_score + min_learning_in_public_score = models.IntegerField(null=True, blank=True) + max_learning_in_public_score = models.IntegerField(null=True, blank=True) + avg_learning_in_public_score = models.FloatField(null=True, blank=True) + median_learning_in_public_score = models.FloatField(null=True, blank=True) + q1_learning_in_public_score = models.FloatField(null=True, blank=True) + q3_learning_in_public_score = models.FloatField(null=True, blank=True) + + # Fields for time_spent_lectures + min_time_spent_lectures = models.FloatField(null=True, blank=True) + max_time_spent_lectures = models.FloatField(null=True, blank=True) + avg_time_spent_lectures = models.FloatField(null=True, blank=True) + median_time_spent_lectures = models.FloatField(null=True, blank=True) + q1_time_spent_lectures = models.FloatField(null=True, blank=True) + q3_time_spent_lectures = models.FloatField(null=True, blank=True) + + # Fields for time_spent_homework + min_time_spent_homework = models.FloatField(null=True, blank=True) + max_time_spent_homework = models.FloatField(null=True, blank=True) + avg_time_spent_homework = models.FloatField(null=True, blank=True) + median_time_spent_homework = models.FloatField(null=True, blank=True) + q1_time_spent_homework = models.FloatField(null=True, blank=True) + q3_time_spent_homework = models.FloatField(null=True, blank=True) + + last_calculated = models.DateTimeField(auto_now=True) + + def get_value(self, field_name, stats_type): + attribute_name = f"{stats_type}_{field_name}" + return getattr(self, attribute_name) + + def get_stat_fields(self): + results = [] + + results.append( + ("Questions score", [ + (self.min_questions_score, "Minimum", "fas fa-arrow-down"), + (self.max_questions_score, "Maximum", "fas fa-arrow-up"), + (self.avg_questions_score, "Average", "fas fa-equals"), + (self.q1_questions_score, "25th Percentile", "fas fa-percentage"), + (self.median_questions_score, "Median", "fas fa-percentage"), + (self.q3_questions_score, "75th Percentile", "fas fa-percentage"), + ], 'fas fa-question-circle') + ) + + results.append( + ("Total score", [ + (self.min_total_score, "Minimum", "fas fa-arrow-down"), + (self.max_total_score, "Maximum", "fas fa-arrow-up"), + (self.avg_total_score, "Average", "fas fa-equals"), + (self.q1_total_score, "25th Percentile", "fas fa-percentage"), + (self.median_total_score, "Median", "fas fa-percentage"), + (self.q3_total_score, "75th Percentile", "fas fa-percentage"), + ], 'fas fa-star') + ) + + results.append( + ("Time spent on lectures", [ + (self.min_time_spent_lectures, "Minimum", "fas fa-arrow-down"), + (self.max_time_spent_lectures, "Maximum", "fas fa-arrow-up"), + (self.avg_time_spent_lectures, "Average", "fas fa-equals"), + (self.q1_time_spent_lectures, "25th Percentile", "fas fa-percentage"), + (self.median_time_spent_lectures, "Median", "fas fa-percentage"), + (self.q3_time_spent_lectures, "75th Percentile", "fas fa-percentage"), + ], 'fas fa-book-reader') + ) + + results.append( + ("Time spent on homework", [ + (self.min_time_spent_homework, "Minimum", "fas fa-arrow-down"), + (self.max_time_spent_homework, "Maximum", "fas fa-arrow-up"), + (self.avg_time_spent_homework, "Average", "fas fa-equals"), + (self.q1_time_spent_homework, "25th Percentile", "fas fa-percentage"), + (self.median_time_spent_homework, "Median", "fas fa-percentage"), + (self.q3_time_spent_homework, "75th Percentile", "fas fa-percentage"), + ], 'fas fa-clock') + ) + + results.append( + ("Learning in public score", [ + (self.min_learning_in_public_score, "Minimum", "fas fa-arrow-down"), + (self.max_learning_in_public_score, "Maximum", "fas fa-arrow-up"), + (self.avg_learning_in_public_score, "Average", "fas fa-equals"), + (self.q1_learning_in_public_score, "25th Percentile", "fas fa-percentage"), + (self.median_learning_in_public_score, "Median", "fas fa-percentage"), + (self.q3_learning_in_public_score, "75th Percentile", "fas fa-percentage"), + ], 'fas fa-globe') + ) + + return results + + def __str__(self): + return f"Statistics for {self.homework.slug}" diff --git a/courses/templates/homework/homework.html b/courses/templates/homework/homework.html index 120f180..ac867ab 100644 --- a/courses/templates/homework/homework.html +++ b/courses/templates/homework/homework.html @@ -41,6 +41,10 @@

This homework is already scored. You didn't submit your answers.

{% endif %} + +
+ Homework statistics +
{% endif %}
diff --git a/courses/templates/homework/stats.html b/courses/templates/homework/stats.html new file mode 100644 index 0000000..596e488 --- /dev/null +++ b/courses/templates/homework/stats.html @@ -0,0 +1,53 @@ +{% extends 'base.html' %} + +{% load static %} +{% load stats_filters %} + +{% block breadcrumbs %} +
  • {{ course.title }}
  • +
  • {{ homework.title }}
  • + +{% endblock %} + +{% block content %} + +

    + {{ homework.title }} statistics +

    + +
    +
    +
    + Total submissions +
    +

    {{ stats.total_submissions }}

    +
    +
    + + +{% for field_name, field_stats, field_icon in stats.get_stat_fields %} +
    +
    +
    + {{ field_name }} +
    +
    +
    +
    + {% for value, label, icon in field_stats %} +
    +
    {{ label }}
    +

    {{ value|floatformat:0 }}

    +
    + {% endfor %} +
    +
    +
    +{% endfor %} + +

    + Calculated: {{ stats.last_calculated }} +

    + + +{% endblock %} \ No newline at end of file diff --git a/courses/urls.py b/courses/urls.py index d125d2b..5848762 100644 --- a/courses/urls.py +++ b/courses/urls.py @@ -28,6 +28,8 @@ course.enrollment_view, name="enrollment", ), + + # project path( "/project/", project.project_view, @@ -63,11 +65,20 @@ project.projects_eval_delete, name="projects_eval_delete", ), + + # homework path( "/homework/", homework.homework_view, name="homework", ), + path( + "/homework//stats", + homework.homework_statistics, + name="homework_statistics", + ), + + # API path( "data//homework/", data.homework_data_view, diff --git a/courses/views/homework.py b/courses/views/homework.py index ccfd78d..d8f296b 100644 --- a/courses/views/homework.py +++ b/courses/views/homework.py @@ -1,14 +1,19 @@ import logging +import statistics + from typing import List, Optional from django.http import HttpRequest +from django.db.models import Count, Min, Max, Avg + from django.contrib import messages from django.utils import timezone from django.shortcuts import render, get_object_or_404, redirect from django.core.exceptions import ValidationError + from courses.models import ( Course, Homework, @@ -19,6 +24,7 @@ QuestionTypes, Enrollment, User, + HomeworkStatistics, ) from courses.scoring import is_free_form_answer_correct @@ -248,9 +254,9 @@ def process_homework_submission( submission.save() success_message = ( - "Thank you for submitting your homework, now your solution " + - "is saved. You can update it at any point. You will see " + - "your score after the form is closed." + "Thank you for submitting your homework, now your solution " + + "is saved. You can update it at any point. You will see " + + "your score after the form is closed." ) messages.success( @@ -316,7 +322,7 @@ def homework_detail_build_context_authenticated( pair = (question, processed_answer) question_answers.append(pair) - disabled = (homework.state != HomeworkState.OPEN.value) + disabled = homework.state != HomeworkState.OPEN.value accepting_submissions = homework.state == HomeworkState.OPEN.value context = { @@ -378,7 +384,111 @@ def homework_view( except ValidationError as e: context["errors"] = e.messages - return render(request, "homework/homework.html", context) +def calculate_statistics(homework_id): + submissions = Submission.objects.filter(homework_id=homework_id) + + fields = [ + "questions_score", + "learning_in_public_score", + "total_score", + "time_spent_lectures", + "time_spent_homework", + ] + + total_submissions = submissions.count() + + stats_renamed = {"total_submissions": total_submissions} + + nones = { + "min": None, + "max": None, + "avg": None, + "q1": None, + "median": None, + "q3": None, + } + + for field in fields: + values = list( + submissions.exclude( + **{f"{field}__isnull": True} + ).values_list(field, flat=True) + ) + + if not values: + stats_renamed[field] = nones + continue + + quantiles = statistics.quantiles( + values, n=4, method="inclusive" + ) + + stats_renamed[field] = { + "min": min(values), + "max": max(values), + "avg": statistics.mean(values), + "q1": quantiles[0], + "median": quantiles[1], + "q3": quantiles[2], + } + + print(f"stats_renamed[{field}]", stats_renamed[field]) + + return stats_renamed + + +def homework_statistics(request, course_slug, homework_slug): + course = get_object_or_404(Course, slug=course_slug) + homework = get_object_or_404( + Homework, course=course, slug=homework_slug + ) + + if not homework.is_scored(): + messages.error( + request, + "This homework is not scored yet, so there are no available statistics.", + extra_tags="homework", + ) + return redirect( + "homework", + course_slug=course.slug, + homework_slug=homework.slug, + ) + + stats, created = HomeworkStatistics.objects.get_or_create( + homework=homework + ) + + if created: + calculated_stats = calculate_statistics(homework.id) + + stats.total_submissions = calculated_stats["total_submissions"] + + for field in [ + "questions_score", + "learning_in_public_score", + "total_score", + "time_spent_lectures", + "time_spent_homework", + ]: + field_stats = calculated_stats[field] + + setattr(stats, f"min_{field}", field_stats["min"]) + setattr(stats, f"max_{field}", field_stats["max"]) + setattr(stats, f"avg_{field}", field_stats["avg"]) + setattr(stats, f"median_{field}", field_stats["median"]) + setattr(stats, f"q1_{field}", field_stats["q1"]) + setattr(stats, f"q3_{field}", field_stats["q3"]) + + stats.save() + + context = { + "course": course, + "homework": homework, + "stats": stats, + } + + return render(request, "homework/stats.html", context)