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 40 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
0c55b23
add todos
fekoch Jul 15, 2024
ac3c5d1
add types
fekoch Jul 15, 2024
56de22c
drop button
fekoch Aug 5, 2024
1e675e1
allow_drop_out setting on evaluations
fekoch Aug 5, 2024
df2833a
add DROPOUT Type for Questionnaire
fekoch Aug 5, 2024
42dd914
[wip] dropout form
fekoch Oct 7, 2024
6a1b0d0
[wip] select default dropout
fekoch Oct 7, 2024
a40e3ed
set_active_dropout
fekoch Oct 14, 2024
ba4ca40
select default for top/bottom questionnaires
fekoch Oct 28, 2024
4153368
UI test for /drop/id route
fekoch Nov 4, 2024
26f5e40
formatting
fekoch Nov 4, 2024
8bb96e4
set initial via initial=
fekoch Nov 4, 2024
e06a8b3
prevent Dropout questionnaires from being set to a different type
fekoch Nov 11, 2024
883c919
merge view logic with vote-view
fekoch Nov 11, 2024
9af6751
add todos
fekoch Nov 11, 2024
6794482
precommit
fekoch Nov 25, 2024
908e2ee
remove dropout questionnaires from avg. calculation
fekoch Nov 11, 2024
7de46ad
inject dropout questionnaire inside get_vote_page_form_groups
fekoch Nov 25, 2024
ec520a6
make dropout-questionnaire un-editable by managers after there are an…
fekoch Nov 25, 2024
8031786
add is_dropout_questionnaire property
fekoch Nov 25, 2024
fd5bf2b
make sure if there are dropout-q's, set one as active
fekoch Nov 25, 2024
9176e9b
add dropout count
fekoch Nov 25, 2024
738accd
can_be_deleted prop for DROPOUT
fekoch Nov 25, 2024
3167bd6
test for dropout counter and allow_drop_out
fekoch Dec 2, 2024
3218ed7
add explanatory texts
fekoch Dec 2, 2024
12ff907
prevent HttpRequest typing issue
fekoch Dec 2, 2024
d0c0b7b
fix too many local variables
fekoch Dec 2, 2024
b9f3075
change naming to placate linter
fekoch Dec 2, 2024
5507165
add dropout questionnaire to testdata
fekoch Dec 16, 2024
7c744be
add todos
fekoch Dec 16, 2024
8107ae0
wip dropout result view
fekoch Dec 16, 2024
b435014
type QuestionnaireManager
fekoch Jan 13, 2025
d8bdaf1
add dropout_questionnaire to general_contribution when setting allow_…
fekoch Jan 13, 2025
476a5d9
do not use allow_drop_out
fekoch Jan 20, 2025
fe399bb
add todo
fekoch Jan 20, 2025
da0fc14
remove active_dropout feature
fekoch Jan 27, 2025
fe18498
UI for dropout
fekoch Jan 27, 2025
368c1a2
add todo
fekoch Jan 27, 2025
ef2f4b6
QuestionnaireTests
fekoch Jan 27, 2025
45b04fb
remove unused imports
fekoch Jan 27, 2025
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",
),
),
]
30 changes: 25 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")

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
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 %}">
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="student@institution.example.com")
cls.user2 = baker.make(UserProfile, email="student2@institution.example.com")

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")
17 changes: 17 additions & 0 deletions evap/results/templates/results_evaluation_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
</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 (
questionnaire_result.questionnaire.type != Questionnaire.Type.DROPOUT
): # 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 @@
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 @@
"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 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)

Check warning on line 306 in evap/results/views.py

View check run for this annotation

Codecov / codecov/patch

evap/results/views.py#L306

Added line #L306 was not covered by tests
else:
top_results.append(questionnaire_result)
elif view != "export" or view_as_user.id == contribution_result.contributor.id:
Expand All @@ -309,7 +313,7 @@
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
Loading