diff --git a/web/attempts/outcome.py b/web/attempts/outcome.py new file mode 100644 index 00000000..e50759fd --- /dev/null +++ b/web/attempts/outcome.py @@ -0,0 +1,82 @@ +from dataclasses import dataclass + +from django.db.models import Count +from django.utils.translation import gettext_lazy as _ + +from .models import Attempt + + +def _group_sizes(queryset, *group_by): + if group_by: + return { + tuple(group): count + for *group, count in queryset.values_list(*group_by).annotate(Count("id")) + } + else: + return {(): queryset.count()} + + +@dataclass +class Outcome: + valid: int = 0 + invalid: int = 0 + total: int = 0 + + def __add__(self, other): + return Outcome( + self.valid + other.valid, + self.invalid + other.invalid, + self.total + other.total, + ) + + @property + def empty(self): + return self.total - self.valid - self.invalid + + @property + def valid_percentage(self): + return int(100 * self.valid / self.total) + + @property + def invalid_percentage(self): + return int(100 * self.invalid / self.total) + + @property + def empty_percentage(self): + return 100 - self.valid_percentage - self.invalid_percentage + + @property + def grade(self): + return min(5, int(5 * self.valid / self.total) + 1) + + def summary(self): + valid = f'{self.valid} { _("valid") }' + invalid = f'{self.invalid} { _("invalid") }' + empty = f'{self.empty} { _("empty") }' + return f"{valid} / {invalid} / {empty}" + + def percentage_summary(self): + valid = f'{self.valid_percentage}% { _("valid") }' + invalid = f'{self.invalid_percentage}% { _("invalid") }' + empty = f'{self.empty_percentage}% { _("empty") }' + return f"{valid} / {invalid} / {empty}" + + @classmethod + def group_dict(cls, parts, users, parts_group_by, users_group_by): + part_group_sizes = _group_sizes(parts, *parts_group_by) + user_group_sizes = _group_sizes(users, *users_group_by) + outcomes = {} + for part_group, part_group_size in part_group_sizes.items(): + for user_group, user_group_size in user_group_sizes.items(): + outcomes[part_group + user_group] = cls( + total=part_group_size * user_group_size + ) + attempts = Attempt.objects.filter(part__in=parts, user__in=users).distinct() + group_by = [f"part__{g}" for g in parts_group_by] + [ + f"user__{g}" for g in users_group_by + ] + for (*group, valid), count in _group_sizes( + attempts, *group_by, "valid" + ).items(): + outcomes[tuple(group)] += cls(valid=count) if valid else cls(invalid=count) + return outcomes diff --git a/web/courses/models.py b/web/courses/models.py index 3e00d9d0..671134ac 100644 --- a/web/courses/models.py +++ b/web/courses/models.py @@ -1,8 +1,8 @@ from copy import deepcopy from attempts.models import Attempt, HistoricalAttempt +from attempts.outcome import Outcome from django.db import models -from django.db.models import Count from django.template.defaultfilters import slugify from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ @@ -48,40 +48,24 @@ def recent_problem_sets(self, n=3): def user_attempts(self, user): """This function ignores problem visibility, because it assumes it is only called from problems/models.py:marking_file() by a teacher user.""" + parts = Part.objects.filter(problem__problem_set__course=self) + users = User.objects.filter(id=user.id) + outcomes = Outcome.group_dict(parts, users, ("problem",), ()) attempts = {} for attempt in user.attempts.filter(part__problem__problem_set__course=self): attempts[attempt.part_id] = attempt sorted_attempts = [] for problem_set in self.problem_sets.all().prefetch_related("problems__parts"): - problem_set_attempts = [] - prob_set_valid = prob_set_invalid = prob_set_empty = 0 + problem_set.outcome = Outcome() + problem_set.attempts = [] for problem in problem_set.problems.all(): - valid = invalid = empty = 0 - problem_attempts = [ + problem.outcome = outcomes[(problem.id,)] + problem.attempts = [ attempts.get(part.pk) for part in problem.parts.all() ] - for attempt in problem_attempts: - if attempt is None: - empty += 1 - elif attempt.valid: - valid += 1 - else: - invalid += 1 - problem_set_attempts.append( - (problem, problem_attempts, valid, invalid, empty) - ) - prob_set_valid += valid - prob_set_invalid += invalid - prob_set_empty += empty - sorted_attempts.append( - ( - problem_set, - problem_set_attempts, - prob_set_valid, - prob_set_invalid, - prob_set_empty, - ) - ) + problem_set.attempts.append(problem) + problem_set.outcome += problem.outcome + sorted_attempts.append(problem_set) return sorted_attempts def prepare_annotated_problem_sets(self, user): @@ -99,77 +83,22 @@ def annotate(self, user): self.annotate_for_user(user) def annotate_for_user(self, user): + parts = Part.objects.filter( + problem__problem_set__course=self, problem__visible=True + ) + users = User.objects.filter(id=user.id) + outcomes = Outcome.group_dict(parts, users, ("problem__problem_set",), ()) for problem_set in self.annotated_problem_sets: - problem_set.percentage = problem_set.valid_percentage(user) - if problem_set.percentage is None: - problem_set.percentage = 0 - problem_set.grade = min(5, int(problem_set.percentage / 20) + 1) + problem_set.outcome = outcomes.get((problem_set.id,), Outcome(0, 0, 1)) def annotate_for_teacher(self): - students = self.observed_students() - student_count = len(students) - - part_sets = Part.objects.filter( - problem__problem_set__in=self.annotated_problem_sets - ) - parts_count = ( - part_sets.values("problem__problem_set_id") - .annotate(count=Count("problem__problem_set_id")) - .order_by("count") + parts = Part.objects.filter( + problem__problem_set__course=self, problem__visible=True ) - parts_dict = {} - for part in parts_count: - problem_set_id = part["problem__problem_set_id"] - parts_dict[problem_set_id] = part["count"] - - attempts_full = Attempt.objects.filter( - user__in=students, - part__problem__problem_set__in=self.annotated_problem_sets, - part__problem__visible=True, - ) - attempts = attempts_full.values("valid", "part__problem__problem_set_id") - attempts_dict = {} - for attempt in attempts: - problem_set_id = attempt["part__problem__problem_set_id"] - if problem_set_id in attempts_dict: - attempts_dict[problem_set_id]["submitted_count"] += 1 - attempts_dict[problem_set_id]["valid_count"] += ( - 1 if attempt["valid"] else 0 - ) - else: - attempts_dict[problem_set_id] = { - "submitted_count": 1, - "valid_count": 1 if attempt["valid"] else 0, - } - + users = self.observed_students() + outcomes = Outcome.group_dict(parts, users, ("problem__problem_set",), ()) for problem_set in self.annotated_problem_sets: - part_count = ( - parts_dict[problem_set.id] if problem_set.id in parts_dict else 0 - ) - - if problem_set.id in attempts_dict: - submitted_count = attempts_dict[problem_set.id]["submitted_count"] - valid_count = attempts_dict[problem_set.id]["valid_count"] - else: - submitted_count = 0 - valid_count = 0 - - invalid_count = submitted_count - valid_count - total_count = student_count * part_count - - if total_count: - valid_percentage = int(100.0 * valid_count / total_count) - invalid_percentage = int(100.0 * invalid_count / total_count) - else: - valid_percentage = 0 - invalid_percentage = 0 - - empty_percentage = 100 - valid_percentage - invalid_percentage - - problem_set.valid = valid_percentage - problem_set.invalid = invalid_percentage - problem_set.empty = empty_percentage - problem_set.grade = min(5, int(valid_percentage / 20) + 1) + problem_set.outcome = outcomes.get((problem_set.id,), Outcome(0, 0, 1)) def enroll_student(self, user): enrollment = StudentEnrollment(course=self, user=user) @@ -197,29 +126,16 @@ def observed_students(self): studentenrollment__course=self, studentenrollment__observed=True ).order_by("first_name") - def student_success(self): - students = self.observed_students() - problem_sets = self.problem_sets.filter(visible=True) - part_count = Part.objects.filter(problem__problem_set__in=problem_sets).count() - attempts = Attempt.objects.filter(part__problem__problem_set__in=problem_sets) - valid_attempts = ( - attempts.filter(valid=True).values("user").annotate(Count("user")) + def student_outcome(self): + parts = Part.objects.filter( + problem__problem_set__course=self, problem__problem_set__visible=True ) - all_attempts = attempts.values("user").annotate(Count("user")) - - def to_dict(attempts): - attempts_dict = {} - for val in attempts: - attempts_dict[val["user"]] = val["user__count"] - return attempts_dict - - valid_attempts_dict = to_dict(valid_attempts) - all_attempts_dict = to_dict(all_attempts) - for student in students: - student.valid = valid_attempts_dict.get(student.pk, 0) - student.invalid = all_attempts_dict.get(student.pk, 0) - student.valid - student.empty = part_count - student.valid - student.invalid - return students + users = self.observed_students() + outcomes = Outcome.group_dict(parts, users, (), ("id",)) + annotated_users = list(users) + for user in annotated_users: + user.outcome = outcomes[(user.id,)] + return annotated_users def duplicate(self): new_course = deepcopy(self) @@ -230,99 +146,6 @@ def duplicate(self): problem_set.copy_to(new_course) return new_course - def student_success_by_problem_set(self): - """ - Function that will for each user calculate the solve rate for each problemset in - the course. For example if the user has solved 3 problem parts out of 10 problem - parts in the problemset, then his solve rate for that problemset would be 30%. - - Returns the dictionary of this form: - { - student : { - problem_set : { - 'valid' : float, - 'invalid' : float, - 'empty' : float - } - } - } - """ - - students = self.observed_students() - student_success_by_problemset = {student: {} for student in students} - student_solve_rate_by_problemset = {student: {} for student in students} - - for problem_set in self.problem_sets.all().prefetch_related( - "problems", "problems__parts" - ): - different_subtasks = 0 - for problem in problem_set.problems.all(): - different_subtasks += problem.parts.count() - - # In case there are no parts, we do not want to divide by 0 - if different_subtasks == 0: - different_subtasks = 1 - - for student in students: - student_success_by_problemset[student][problem_set] = [ - 0, - 0, - ] # Valid, invalid - - attempts = Attempt.objects.filter( - part__problem__problem_set=problem_set, - user__in=students, - ) - for attempt in attempts: - if attempt.valid: - student_success_by_problemset[attempt.user][problem_set][0] += 1 - else: - student_success_by_problemset[attempt.user][problem_set][1] += 1 - - for student in students: - valid, invalid = student_success_by_problemset[student][problem_set] - student_solve_rate_by_problemset[student][problem_set] = { - "valid": round(valid / different_subtasks, 2), - "invalid": round(invalid / different_subtasks, 2), - "empty": round(1 - (valid + invalid) / different_subtasks, 2), - } - - return student_solve_rate_by_problemset - - def student_success_by_problemset_grouped_by_groups(self): - """ - Function does the same as student_success_by_problem_set except that it groups - students that are in the same group. Therefore we get a dictionary that will be - easier to use inside a django template. An alternative would be to create django - templatetags app and define filters with which we could access dictionary keys - inside django templates. - - Returns a dictionary of the following form: - - { - group : { - student : { - problem_set : { - 'valid' : float, - 'invalid' : float, - 'empty' : float - } - } - ] - } - """ - - groups = self.groups.all() - student_success = self.student_success_by_problem_set() - - student_success_by_groups = {} - for group in groups: - student_success_by_groups[group] = {} - for student in group.students.all(): - student_success_by_groups[group][student] = student_success[student] - - return student_success_by_groups - class StudentEnrollment(models.Model): course = models.ForeignKey(Course, on_delete=models.CASCADE) @@ -486,46 +309,16 @@ def results_archive(self, user): files.append((spreadsheet_filename, spreadsheet_contents)) return archive_name, files - def student_statistics(self): - students = self.course.observed_students() - student_count = len(students) - - attempts = Attempt.objects.filter( - part__problem__problem_set=self, user__in=students - ).values("part_id", "valid") - attempts_dict = {} - for attempt in attempts: - part_id = attempt["part_id"] - if part_id in attempts_dict: - attempts_dict[part_id]["valid_count"] += 1 if attempt["valid"] else 0 - attempts_dict[part_id]["submitted_count"] += 1 - else: - attempts_dict[part_id] = { - "submitted_count": 1, - "valid_count": 1 if attempt["valid"] else 0, - } - + def outcomes_statistics(self, outcomes): statistics = [] - for problem in self.problems.all(): + for problem in self.problems.prefetch_related("parts"): parts = [] for part in problem.parts.all(): - if part.id in attempts_dict: - submitted_count = attempts_dict[part.id]["submitted_count"] - valid_count = attempts_dict[part.id]["valid_count"] - else: - submitted_count = 0 - valid_count = 0 - - invalid_count = submitted_count - valid_count - empty_count = student_count - valid_count - invalid_count - parts.append( { "anchor": part.anchor(), "pk": part.pk, - "valid": valid_count, - "invalid": invalid_count, - "empty": empty_count, + "outcome": outcomes[(part.pk,)], } ) statistics.append( @@ -539,20 +332,17 @@ def student_statistics(self): ) return statistics - def valid_percentage(self, user): - """ - Returns the percentage of parts (rounded to the nearest integer) - of parts in this problem set for which the given user has a valid attempt. - Counts parts of problems all problems (even if visible is set to false). - """ - number_of_all_parts = Part.objects.filter(problem__problem_set=self).count() - number_of_valid_parts = user.attempts.filter( - valid=True, part__problem__problem_set=self - ).count() - if number_of_all_parts == 0: - return None - else: - return int(round(100.0 * number_of_valid_parts / number_of_all_parts)) + def single_student_statistics(self, student): + students = User.objects.filter(id=student.id) + parts = Part.objects.filter(problem__problem_set=self, problem__visible=True) + outcomes = Outcome.group_dict(parts, students, ("id",), ()) + return self.outcomes_statistics(outcomes) + + def all_students_statistics(self): + students = self.course.observed_students() + parts = Part.objects.filter(problem__problem_set=self) + outcomes = Outcome.group_dict(parts, students, ("id",), ()) + return self.outcomes_statistics(outcomes) def toggle_visible(self): self.visible = not self.visible diff --git a/web/courses/views.py b/web/courses/views.py index b34b344d..c31163a4 100644 --- a/web/courses/views.py +++ b/web/courses/views.py @@ -100,12 +100,12 @@ def problem_set_detail(request, problem_set_pk): verify(request.user.can_view_problem_set(problem_set)) user_attempts = request.user.attempts.filter( - part__problem__problem_set__id=problem_set_pk + part__problem__problem_set=problem_set, part__problem__visible=True ) - student_statistics = problem_set.student_statistics() - if not request.user.is_teacher(problem_set.course): - user_attempts = user_attempts.filter(part__problem__visible=True) - student_statistics = list(filter(lambda p: p["visible"], student_statistics)) + if request.user.is_teacher(problem_set.course): + student_statistics = problem_set.all_students_statistics() + else: + student_statistics = problem_set.single_student_statistics(request.user) valid_parts_ids = user_attempts.filter(valid=True).values_list("part_id", flat=True) invalid_parts_ids = user_attempts.filter(valid=False).values_list( "part_id", flat=True @@ -116,6 +116,7 @@ def problem_set_detail(request, problem_set_pk): "courses/problem_set_detail.html", { "problem_set": problem_set, + "problems": problem_set.problems.prefetch_related("parts"), "valid_parts_ids": valid_parts_ids, "invalid_parts_ids": invalid_parts_ids, "show_teacher_forms": request.user.can_edit_problem_set(problem_set), @@ -130,7 +131,7 @@ def course_detail(request, course_pk): course = get_object_or_404(Course, pk=course_pk) verify(request.user.can_view_course(course)) if request.user.can_edit_course(course): - students = course.student_success() + students = course.student_outcome() else: students = [] @@ -347,7 +348,6 @@ def course_groups(request, course_pk): { "course": course, "show_teacher_forms": request.user.can_create_course_groups(course), - # 'success' : course.student_success_by_problemset_grouped_by_groups() }, ) diff --git a/web/problems/models.py b/web/problems/models.py index b0a824a1..fc0d9371 100644 --- a/web/problems/models.py +++ b/web/problems/models.py @@ -1,6 +1,7 @@ import json from copy import deepcopy +from attempts.outcome import Outcome from django.conf import settings from django.core import signing from django.db import models @@ -160,36 +161,21 @@ def edit_file(self, user): def attempts_by_user(self, active_only=True): attempts = {} - for part in self.parts.all().prefetch_related("attempts", "attempts__user"): + parts = self.parts.all().prefetch_related("attempts", "attempts__user") + for part in parts: for attempt in part.attempts.all(): if attempt.user in attempts: attempts[attempt.user][part] = attempt else: attempts[attempt.user] = {part: attempt} - for student in self.problem_set.course.students.all(): - if student not in attempts: - attempts[student] = {} - - observed_students = self.problem_set.course.observed_students() - + users = self.problem_set.course.observed_students() if active_only: - observed_students = observed_students.filter( - attempts__part__problem=self - ).distinct() - - observed_students = list(observed_students) + users = users.filter(attempts__part__problem=self).distinct() + outcomes = Outcome.group_dict(parts, users, (), ("id",)) + observed_students = list(users) for user in observed_students: - user.valid = user.invalid = user.empty = 0 - user.these_attempts = [ - attempts[user].get(part) for part in self.parts.all() - ] - for attempt in user.these_attempts: - if attempt is None: - user.empty += 1 - elif attempt.valid: - user.valid += 1 - else: - user.invalid += 1 + user.these_attempts = [attempts.get(user, {}).get(part) for part in parts] + user.outcome = outcomes[(user.id,)] return observed_students def attempts_by_user_all(self): diff --git a/web/templates/courses/_problem_set.html b/web/templates/courses/_problem_set.html index df128f86..b36bd53f 100644 --- a/web/templates/courses/_problem_set.html +++ b/web/templates/courses/_problem_set.html @@ -4,20 +4,20 @@ {% if course.is_taught %} {% spaceless %}
- {% if problem_set.valid %} -
+ title="{{ problem_set.outcome.percentage_summary }} "> + {% if problem_set.outcome.valid_percentage %} +
{% endif %} - {% if problem_set.invalid %} -
+ {% if problem_set.outcome.invalid_percentage %} +
{% endif %} - {% if problem_set.empty %} -
+ {% if problem_set.outcome.empty_percentage %} +
{% endif %}
{% endspaceless %} {% else %} -
+
{% endif %}
@@ -101,19 +101,11 @@
{% endif %} - {% if course.is_taught %}
-

- {{ problem_set.valid }}% +

+ {{ problem_set.outcome.valid_percentage }}%

- {% else %} -
-

- {{ problem_set.percentage }}% -

-
- {% endif %}
diff --git a/web/templates/courses/course_detail.html b/web/templates/courses/course_detail.html index f20c5428..7fa0153d 100644 --- a/web/templates/courses/course_detail.html +++ b/web/templates/courses/course_detail.html @@ -126,9 +126,9 @@ $(function() { {% for student in students %} $("#student{{student.pk}}").drawPieChart([ - { value : {{ student.empty }}, color: "#A92D2D" }, - { value: {{ student.invalid }}, color: "#D7A02C" }, - { value : {{ student.valid }}, color: "#878F28" } + { value: {{ student.outcome.empty }}, color: "#A92D2D" }, + { value: {{ student.outcome.invalid }}, color: "#D7A02C" }, + { value: {{ student.outcome.valid }}, color: "#878F28" } ]); {% endfor %} }); diff --git a/web/templates/courses/course_progress.html b/web/templates/courses/course_progress.html index be04f072..be8241ef 100644 --- a/web/templates/courses/course_progress.html +++ b/web/templates/courses/course_progress.html @@ -14,27 +14,27 @@

{{ observed_user.title }}

- {% for problem_set, problem_set_attempts, ps_valid, ps_invalid, ps_empty in course_attempts %} + {% for problem_set in course_attempts %}
- {% for problem, problem_attempts, valid, invalid, empty in problem_set_attempts %} + {% for problem in problem_set.attempts %}
+ title="{{ problem.outcome.summary }} "> {{ problem.title }} - {% for attempt in problem_attempts %} + {% for attempt in problem.attempts %} {% if attempt.valid %} {% elif attempt %} diff --git a/web/templates/courses/problem_set_detail.html b/web/templates/courses/problem_set_detail.html index f0892fce..2479e880 100644 --- a/web/templates/courses/problem_set_detail.html +++ b/web/templates/courses/problem_set_detail.html @@ -83,7 +83,7 @@


{% endif %} - {% for problem in problem_set.problems.all %} + {% for problem in problems %} {% if problem.visible or show_teacher_forms %}
@@ -195,7 +195,8 @@
{{ problem.title }}
{% endif %}
    - {% for part in problem.parts.all %} + {% with problem.parts.all as parts %} + {% for part in parts %} {% if part.pk in valid_parts_ids %}
  • {% elif part.pk in invalid_parts_ids %} @@ -204,12 +205,13 @@
    {{ problem.title }}
  • {% endif %} {# Translators: problem set detail #} - {% if problem.parts.count > 1 %} + {% if parts|length > 1 %}
    {{ forloop.counter }}. {% trans "part" %}
    {% endif %}

    {{ part.guarded_description|latex_markdown }}

  • {% endfor %} + {% endwith %}
{% endif %} @@ -238,7 +240,7 @@
{{ forloop.counter }}. {% trans "part" %}
{% if show_teacher_forms %}
+ title="{{ part.outcome.summary }} "> {% else %} {% endif %} @@ -263,23 +265,11 @@
{{ forloop.counter }}. {% trans "part" %}
$(function() { {% for problem in student_statistics %} {% for part in problem.parts %} - {% if show_teacher_forms %} $("#pieChart-{{ problem.pk }}-{{ part.pk }}").drawPieChart([ - {value: {{ part.valid }}, color: "#878F28"}, - {value: {{ part.invalid }}, color: "#D7A02C"}, - {value: {{ part.empty }}, color: "#A92D2D"}, + {value: {{ part.outcome.valid }}, color: "#878F28"}, + {value: {{ part.outcome.invalid }}, color: "#D7A02C"}, + {value: {{ part.outcome.empty }}, color: "#A92D2D"}, ]); - {% else %} - $("#pieChart-{{ problem.pk }}-{{ part.pk }}").drawPieChart([ - {% if part.pk in valid_parts_ids %} - {value: 1, color: "#878F28"}, - {% elif part.pk in invalid_parts_ids %} - {value: 1, color: "#D7A02C"}, - {% else %} - {value: 1, color: "#A92D2D"}, - {% endif %} - ]); - {% endif %} {% endfor %} {% endfor %} }); diff --git a/web/templates/courses/problem_set_progress.html b/web/templates/courses/problem_set_progress.html index 6fa043f5..72e98a5b 100644 --- a/web/templates/courses/problem_set_progress.html +++ b/web/templates/courses/problem_set_progress.html @@ -34,7 +34,7 @@

{{ proble

+ title="{{ observed_user.outcome.summary }}"> {{ observed_user.get_full_name }} diff --git a/web/templates/courses/problem_set_progress_groups.html b/web/templates/courses/problem_set_progress_groups.html index de4ebc8e..75290dd2 100644 --- a/web/templates/courses/problem_set_progress_groups.html +++ b/web/templates/courses/problem_set_progress_groups.html @@ -35,7 +35,7 @@

{{ proble

+ title="{{ observed_user.outcome.summary }} "> {{ observed_user.get_full_name }} diff --git a/web/users/models.py b/web/users/models.py index ad2c2aa5..0efbccfa 100644 --- a/web/users/models.py +++ b/web/users/models.py @@ -2,6 +2,7 @@ from django.contrib.auth.models import AbstractUser from django.db.models.signals import post_save from django.dispatch import receiver +from django.utils.functional import cached_property from rest_framework.authtoken.models import Token @@ -48,6 +49,7 @@ def uses_shibboleth(self): def is_teacher(self, course): return self in course.teachers.all() + @cached_property def is_teacher_anywhere(self): return self.taught_courses.exists()