Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dropped Courses #2262

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 194 additions & 6 deletions evap/development/fixtures/test_data.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Generated by Django 5.1.3 on 2025-01-27 18:32

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("evaluation", "0147_unusable_password_default"),
]

operations = [
migrations.AddField(
model_name="evaluation",
name="dropout_count",
field=models.IntegerField(default=0, verbose_name="dropout count"),
),
migrations.AlterField(
model_name="questionnaire",
name="type",
field=models.IntegerField(
choices=[
(10, "Top questionnaire"),
(20, "Contributor questionnaire"),
(30, "Bottom questionnaire"),
(40, "Dropout questionnaire"),
],
default=10,
verbose_name="type",
),
),
]
31 changes: 26 additions & 5 deletions evap/evaluation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from django.core.exceptions import ValidationError
from django.core.mail import EmailMessage, EmailMultiAlternatives
from django.db import IntegrityError, models, transaction
from django.db.models import CheckConstraint, Count, Exists, F, Manager, OuterRef, Q, Subquery, Value
from django.db.models import CheckConstraint, Count, Exists, F, Manager, OuterRef, Q, QuerySet, Subquery, Value
from django.db.models.functions import Coalesce, Lower, NullIf, TruncDate
from django.dispatch import Signal, receiver
from django.http import HttpRequest
Expand Down Expand Up @@ -157,13 +157,18 @@ def evaluations(self):
return Evaluation.objects.filter(course__semester=self)


class QuestionnaireManager(Manager):
def general_questionnaires(self):
return super().get_queryset().exclude(type=Questionnaire.Type.CONTRIBUTOR)
class QuestionnaireManager(Manager["Questionnaire"]):
def general_questionnaires(self) -> QuerySet["Questionnaire"]:
return (
super().get_queryset().exclude(type=Questionnaire.Type.CONTRIBUTOR).exclude(type=Questionnaire.Type.DROPOUT)
)

def contributor_questionnaires(self):
def contributor_questionnaires(self) -> QuerySet["Questionnaire"]:
return super().get_queryset().filter(type=Questionnaire.Type.CONTRIBUTOR)

def dropout_questionnaires(self) -> QuerySet["Questionnaire"]:
return super().get_queryset().filter(type=Questionnaire.Type.DROPOUT)


class Questionnaire(models.Model):
"""A named collection of questions."""
Expand All @@ -172,6 +177,7 @@ class Type(models.IntegerChoices):
TOP = 10, _("Top questionnaire")
CONTRIBUTOR = 20, _("Contributor questionnaire")
BOTTOM = 30, _("Bottom questionnaire")
DROPOUT = 40, _("Dropout questionnaire")
Comment on lines 177 to +180
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would probably be better

Suggested change
TOP = 10, _("Top questionnaire")
CONTRIBUTOR = 20, _("Contributor questionnaire")
BOTTOM = 30, _("Bottom questionnaire")
DROPOUT = 40, _("Dropout questionnaire")
DROPOUT = 05, _("Dropout questionnaire")
TOP = 10, _("Top questionnaire")
CONTRIBUTOR = 20, _("Contributor questionnaire")
BOTTOM = 30, _("Bottom questionnaire")


type = models.IntegerField(choices=Type.choices, verbose_name=_("type"), default=Type.TOP)

Expand Down Expand Up @@ -232,6 +238,14 @@ def is_above_contributors(self):
def is_below_contributors(self):
return self.type == self.Type.BOTTOM

@property
def is_dropout_questionnaire(self):
return self.type == self.Type.DROPOUT

@property
def is_general_questionnaire(self):
return self.type in (self.Type.TOP, self.Type.BOTTOM)

@property
def can_be_edited_by_manager(self):
if is_prefetched(self, "contributions"):
Expand Down Expand Up @@ -455,6 +469,8 @@ class State(models.IntegerChoices):
)
_voter_count = models.IntegerField(verbose_name=_("voter count"), blank=True, null=True, default=None)

dropout_count = models.IntegerField(verbose_name=_("dropout count"), default=0)

# when the evaluation takes place
vote_start_datetime = models.DateTimeField(verbose_name=_("start of evaluation"))
# Usually the property vote_end_datetime should be used instead of this field
Expand Down Expand Up @@ -620,6 +636,10 @@ def runtime(self):
def is_in_evaluation_period(self):
return self.vote_start_datetime <= datetime.now() <= self.vote_end_datetime

@property
def is_dropout_allowed(self):
return self.general_contribution.questionnaires.filter(type=Questionnaire.Type.DROPOUT).exists()

@property
def general_contribution_has_questionnaires(self):
return self.general_contribution and self.general_contribution.questionnaires.count() > 0
Expand Down Expand Up @@ -1049,6 +1069,7 @@ def unlogged_fields(self):
"can_publish_text_results",
"_voter_count",
"_participant_count",
"dropout_count",
]


Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
{% load evaluation_filters %}

<!-- TODO: stimmt dieser template Name ? -->

<fieldset>
{% for field in evaluation_form %}
{% if field == evaluation_form.general_questionnaires %}
{% if field == evaluation_form.general_questionnaires or field == evaluation_form.dropout_questionnaires %}
<div class="mb-3 d-flex">
{% include 'bootstrap_form_field_label.html' with field=field class='col-md-3 pe-4' %}
<div class="col-md-7{% if field.errors %} is-invalid{% endif %}">
Comment on lines 1 to 10
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@janno42 Is this a good name for this template?

Expand Down
92 changes: 91 additions & 1 deletion evap/evaluation/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from django.urls import reverse
from model_bakery import baker

from evap.evaluation.models import Evaluation, Question, QuestionType, Semester, UserProfile
from evap.evaluation.models import NO_ANSWER, Evaluation, Question, Questionnaire, QuestionType, Semester, UserProfile
from evap.evaluation.tests.tools import (
WebTest,
WebTestWith200Check,
Expand All @@ -14,6 +14,7 @@
store_ts_test_asset,
)
from evap.staff.tests.utils import WebTestStaffMode
from evap.student.tools import answer_field_id


class RenderJsTranslationCatalog(WebTest):
Expand Down Expand Up @@ -284,3 +285,92 @@ def test_reset_to_new(self) -> None:
self.reset_from_x_to_new(s, success_expected=True)
for s in invalid_start_states:
self.reset_from_x_to_new(s, success_expected=False)


class TestDropoutQuestionnaire(WebTest):
@classmethod
def setUpTestData(cls) -> None:
cls.user = baker.make(UserProfile, email="[email protected]")
cls.user2 = baker.make(UserProfile, email="[email protected]")

cls.question = baker.make(Question, type=QuestionType.POSITIVE_YES_NO)

cls.normal_questionnaire = baker.make(
Questionnaire,
type=Questionnaire.Type.TOP,
questions=[
baker.make(Question, type=QuestionType.TEXT),
baker.make(Question, type=QuestionType.EASY_DIFFICULT),
],
)
cls.dropout_questionnaire = baker.make(Questionnaire, type=Questionnaire.Type.DROPOUT, questions=[cls.question])

cls.evaluation = baker.make(
Evaluation, state=Evaluation.State.IN_EVALUATION, participants=[cls.user, cls.user2]
)

cls.evaluation.general_contribution.questionnaires.add(cls.dropout_questionnaire, cls.normal_questionnaire)

def assert_no_answer_set_everywhere(self, form):
for name, fields in form.fields.items():
if name is not None and name.startswith("question_"):
field = fields[0]
if field.tag == "textarea":
self.assertEqual(
fields[0].value,
"",
f"Answers to Questions in the general contribution should be set to NO_ANSWER (eg. {NO_ANSWER})",
)
else:
self.assertEqual(
fields[0].value,
str(NO_ANSWER),
f"Answers to Questions in the general contribution should be set to NO_ANSWER (eg. {NO_ANSWER})",
)

def test_choosing_dropout_sets_to_no_answer(self):
response = self.app.get(url=reverse("student:drop", args=[self.evaluation.id]), user=self.user, status=200)
form = response.forms["student-vote-form"]

self.assertIn(
answer_field_id(self.evaluation.general_contribution, self.dropout_questionnaire, self.question),
form.fields.keys(),
"The dropout Questionnaire should be shown",
)
self.assert_no_answer_set_everywhere(form)

def test_dropout_possible_iff_dropout_questionnaire_attached(self):
self.assertTrue(self.evaluation.is_dropout_allowed)
self.assertTrue(
self.evaluation.general_contribution.questionnaires.filter(type=Questionnaire.Type.DROPOUT).exists()
)

normal_questionnaires = self.evaluation.general_contribution.questionnaires.exclude(
type=Questionnaire.Type.DROPOUT
).all()
self.evaluation.general_contribution.questionnaires.set(normal_questionnaires)

self.assertFalse(self.evaluation.is_dropout_allowed)
self.assertFalse(
self.evaluation.general_contribution.questionnaires.filter(type=Questionnaire.Type.DROPOUT).exists()
)

def test_dropping_out_increments_dropout_counter(self):
self.assertEqual(self.evaluation.dropout_count, 0, "dropout_count should be initially zero")

form = self.app.get(url=reverse("student:drop", args=[self.evaluation.id]), user=self.user, status=200).forms[
"student-vote-form"
]
form.submit()
evaluation = Evaluation.objects.get(pk=self.evaluation.pk)

self.assertEqual(evaluation.dropout_count, 1, "dropout count should increment with dropout")

form = self.app.get(url=reverse("student:vote", args=[self.evaluation.id]), user=self.user2, status=200).forms[
"student-vote-form"
]
form.submit()
evaluation = Evaluation.objects.get(pk=self.evaluation.pk)

self.assertEqual(evaluation.dropout_count, 1, "dropout_count should not change on normal vote")
self.assertEqual(self.evaluation.dropout_count, 0, "other evaluation should not have been changed")
19 changes: 18 additions & 1 deletion evap/results/templates/results_evaluation_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ <h3>{{ evaluation.full_name }} ({{ evaluation.course.semester.name }})</h3>
{% endif %}

{% if general_questionnaire_results_bottom %}
<div class="card card-outline-primary">
<div class="card card-outline-primary mb-3">
<div class="card-header">
{% translate 'General' %}
</div>
Expand All @@ -227,6 +227,23 @@ <h3>{{ evaluation.full_name }} ({{ evaluation.course.semester.name }})</h3>
</div>
{% endif %}

{% if evaluation.is_dropout_allowed or evaluation.dropout_count > 0 or dropout_questionnaire_results %}
<div class="card card-outline-primary mb-3">
<div class="card-header d-flex flex-row justify-content-between">
<span>{% translate 'Dropout' %}</span>

<div class="badge-participants badge-participants-{{ evaluation.dropout_count|participationclass:evaluation.num_voters }} ms-2 ms-lg-3">
<span class="fas fa-user"></span> {{ evaluation.dropout_count }}
</div>
Comment on lines +235 to +237
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this shouldn't use bold text (inherited from card-header)

</div>
<div class="card-body">
{% for questionnaire_result in dropout_questionnaire_results %}
{% include 'results_evaluation_detail_questionnaires.html' %}
{% endfor %}
</div>
</div>
{% endif %}

{# Leave some space for big tooltips #}
<div class="py-5 py-md-0"></div>
{% endblock %}
19 changes: 19 additions & 0 deletions evap/results/tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,25 @@ def test_calculate_average_course_distribution(self):
self.assertEqual(distribution[3], 0)
self.assertEqual(distribution[4], 0)

def test_dropout_questionnaires_are_not_included(self):
general_questionnaire = baker.make(Questionnaire, type=Questionnaire.Type.TOP)
general_question = baker.make(Question, questionnaire=general_questionnaire, type=QuestionType.GRADE)

dropout_questionnaire = baker.make(Questionnaire, type=Questionnaire.Type.DROPOUT)
dropout_question = baker.make(Question, questionnaire=dropout_questionnaire, type=QuestionType.GRADE)

contribution = baker.make(
Contribution, evaluation=self.evaluation, questionnaires=[general_questionnaire, dropout_questionnaire]
)

make_rating_answer_counters(general_question, contribution, [10, 10, 0, 0, 0])
make_rating_answer_counters(dropout_question, contribution, [0, 0, 0, 0, 10])

cache_results(self.evaluation)

calculated_grade = distribution_to_grade(calculate_average_distribution(self.evaluation))
self.assertAlmostEqual(calculated_grade, 1.5)


class TestTextAnswerVisibilityInfo(TestCase):
@classmethod
Expand Down
5 changes: 4 additions & 1 deletion evap/results/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,10 @@ def calculate_average_distribution(evaluation):
grouped_results = defaultdict(list)
for contribution_result in get_results(evaluation).contribution_results:
for questionnaire_result in contribution_result.questionnaire_results:
grouped_results[contribution_result.contributor].extend(questionnaire_result.question_results)
if (
not questionnaire_result.questionnaire.is_dropout_questionnaire
): # dropout questionnaires are not counted
grouped_results[contribution_result.contributor].extend(questionnaire_result.question_results)

evaluation_results = grouped_results.pop(None, [])

Expand Down
12 changes: 8 additions & 4 deletions evap/results/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,8 @@ def evaluation_detail(request, semester_id, evaluation_id):
remove_empty_questionnaire_and_contribution_results(evaluation_result)
add_warnings(evaluation, evaluation_result)

top_results, bottom_results, contributor_results = split_evaluation_result_into_top_bottom_and_contributor(
evaluation_result, view_as_user, view
top_results, bottom_results, contributor_results, dropout_results = (
split_evaluation_result_into_questionnaire_types(evaluation_result, view_as_user, view)
)

course_evaluations = get_evaluations_of_course(evaluation.course, request)
Expand Down Expand Up @@ -206,6 +206,7 @@ def evaluation_detail(request, semester_id, evaluation_id):
"general_questionnaire_results_top": top_results,
"general_questionnaire_results_bottom": bottom_results,
"contributor_contribution_results": contributor_results,
"dropout_questionnaire_results": dropout_results,
"is_reviewer": view_as_user.is_reviewer,
"is_contributor": evaluation.is_user_contributor(view_as_user),
"is_responsible_or_contributor_or_delegate": is_responsible_or_contributor_or_delegate,
Expand Down Expand Up @@ -290,16 +291,19 @@ def remove_empty_questionnaire_and_contribution_results(evaluation_result):
]


def split_evaluation_result_into_top_bottom_and_contributor(evaluation_result, view_as_user, view):
def split_evaluation_result_into_questionnaire_types(evaluation_result, view_as_user, view):
top_results = []
bottom_results = []
contributor_results = []
dropout_results = []

for contribution_result in evaluation_result.contribution_results:
if contribution_result.contributor is None:
for questionnaire_result in contribution_result.questionnaire_results:
if questionnaire_result.questionnaire.is_below_contributors:
bottom_results.append(questionnaire_result)
elif questionnaire_result.questionnaire.is_dropout_questionnaire:
dropout_results.append(questionnaire_result)
else:
top_results.append(questionnaire_result)
elif view != "export" or view_as_user.id == contribution_result.contributor.id:
Expand All @@ -309,7 +313,7 @@ def split_evaluation_result_into_top_bottom_and_contributor(evaluation_result, v
top_results += bottom_results
bottom_results = []

return top_results, bottom_results, contributor_results
return top_results, bottom_results, contributor_results, dropout_results


def get_evaluations_of_course(course, request):
Expand Down
Loading