From 75b282a70404eb799927676d1f5caa363c13ecf0 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 12 May 2025 20:59:34 +0200 Subject: [PATCH 01/57] fix allows additional textanswers saving bug When changing a the type from text/heading to a option that allows additional textanswers and clicking the checkbox, it would not be saved. This is due to the fact, that this form field was disabled and therefore was not send for the save. I now replaced this with and attribut so I can be disabled in the frontend and be send for the submit. --- evap/staff/forms.py | 2 +- evap/staff/templates/staff_questionnaire_form.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/evap/staff/forms.py b/evap/staff/forms.py index 8e3183d2ca..ec4db5bc1a 100644 --- a/evap/staff/forms.py +++ b/evap/staff/forms.py @@ -917,7 +917,7 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self.instance.pk and self.instance.type in [QuestionType.TEXT, QuestionType.HEADING]: - self.fields["allows_additional_textanswers"].disabled = True + self.fields["allows_additional_textanswers"].widget.attrs["disabled"] = "disabled" def clean(self): super().clean() diff --git a/evap/staff/templates/staff_questionnaire_form.html b/evap/staff/templates/staff_questionnaire_form.html index 2201d91771..b3feb8fea3 100644 --- a/evap/staff/templates/staff_questionnaire_form.html +++ b/evap/staff/templates/staff_questionnaire_form.html @@ -58,7 +58,7 @@
{% translate 'Questions' %}
+ {% if 'disabled' in form_element.allows_additional_textanswers.field.widget.attrs %} disabled{% endif %} />
From c077bf6ea171d38e0d621f34d827f0300308dc01 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Tue, 13 May 2025 00:58:52 +0200 Subject: [PATCH 02/57] added counts for grade on staff side added field to model added migration added checkbox to form/template on staff questionaire form --- .../0148_question_counts_for_grade.py | 18 ++++++++++++++++++ evap/evaluation/models.py | 5 ++++- evap/staff/forms.py | 4 +++- .../templates/staff_questionnaire_form.html | 19 +++++++++++++++---- 4 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 evap/evaluation/migrations/0148_question_counts_for_grade.py diff --git a/evap/evaluation/migrations/0148_question_counts_for_grade.py b/evap/evaluation/migrations/0148_question_counts_for_grade.py new file mode 100644 index 0000000000..1efcdcfd3a --- /dev/null +++ b/evap/evaluation/migrations/0148_question_counts_for_grade.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2025-05-12 21:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("evaluation", "0147_unusable_password_default"), + ] + + operations = [ + migrations.AddField( + model_name="question", + name="counts_for_grade", + field=models.BooleanField(default=True, verbose_name="counts toward the evaluations grade"), + ), + ] diff --git a/evap/evaluation/models.py b/evap/evaluation/models.py index 79d7c6a901..10a7dc68bd 100644 --- a/evap/evaluation/models.py +++ b/evap/evaluation/models.py @@ -1268,6 +1268,7 @@ class Question(models.Model): text_en = models.CharField(max_length=1024, verbose_name=_("question text (english)")) text = translate(en="text_en", de="text_de") allows_additional_textanswers = models.BooleanField(default=True, verbose_name=_("allow additional text answers")) + counts_for_grade = models.BooleanField(default=True, verbose_name=_("counts toward the evaluations grade")) type = models.PositiveSmallIntegerField(choices=QUESTION_TYPES, verbose_name=_("question type")) @@ -1289,7 +1290,9 @@ def save(self, *args, **kwargs): if self.type in [QuestionType.TEXT, QuestionType.HEADING]: self.allows_additional_textanswers = False if "update_fields" in kwargs: - kwargs["update_fields"] = {"allows_additional_textanswers"}.union(kwargs["update_fields"]) + kwargs["update_fields"] = {"allows_additional_textanswers", "counts_for_grade"}.union( + kwargs["update_fields"] + ) super().save(*args, **kwargs) diff --git a/evap/staff/forms.py b/evap/staff/forms.py index ec4db5bc1a..0f59bab22f 100644 --- a/evap/staff/forms.py +++ b/evap/staff/forms.py @@ -907,7 +907,7 @@ def save(self, commit=True): class QuestionForm(forms.ModelForm): class Meta: model = Question - fields = ("order", "questionnaire", "text_de", "text_en", "type", "allows_additional_textanswers") + fields = ("order", "questionnaire", "text_de", "text_en", "type", "allows_additional_textanswers", "counts_for_grade") widgets = { "text_de": forms.Textarea(attrs={"rows": 2}), "text_en": forms.Textarea(attrs={"rows": 2}), @@ -918,11 +918,13 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self.instance.pk and self.instance.type in [QuestionType.TEXT, QuestionType.HEADING]: self.fields["allows_additional_textanswers"].widget.attrs["disabled"] = "disabled" + self.fields["counts_for_grade"].widget.attrs["disabled"] = "disabled" def clean(self): super().clean() if self.cleaned_data.get("type") in [QuestionType.TEXT, QuestionType.HEADING]: self.cleaned_data["allows_additional_textanswers"] = False + self.cleaned_data["counts_for_grade"] = False return self.cleaned_data diff --git a/evap/staff/templates/staff_questionnaire_form.html b/evap/staff/templates/staff_questionnaire_form.html index b3feb8fea3..05010e41e4 100644 --- a/evap/staff/templates/staff_questionnaire_form.html +++ b/evap/staff/templates/staff_questionnaire_form.html @@ -61,6 +61,12 @@
{% translate 'Questions' %}
{% if 'disabled' in form_element.allows_additional_textanswers.field.widget.attrs %} disabled{% endif %} /> +
+ + +
{% include 'bootstrap_form_field_widget.html' with field=form_element.DELETE %} @@ -98,12 +104,17 @@
{% translate 'Questions' %}
makeFormSortable("question_table", "questions", rowChanged, rowAdded, true, true); const selectChangedHandler = function(e) { - const checkbox = this.closest("td.question-type").querySelector("input[type=checkbox]"); + const checkboxes = this.closest("td.question-type").querySelectorAll("input[type=checkbox]"); if (e.currentTarget.value == 0 || e.currentTarget.value == 5) { // 0: Text question; 5: Heading - checkbox.checked = false; - checkbox.disabled = true; + checkboxes.forEach(checkbox => { + checkbox.checked = false; + checkbox.disabled = true; + }); } else { - checkbox.disabled = false; + checkboxes.forEach(checkbox => { + checkbox.checked = true; + checkbox.disabled = false; + }); } }; From 178b77a91447c8427c72d4976b6a519aa20f1dbe Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 26 May 2025 21:06:52 +0200 Subject: [PATCH 03/57] counts for grade on student side display info on questions --- evap/staff/forms.py | 10 +++++++++- evap/student/forms.py | 5 ++++- .../templates/student_vote_questionnaire_group.html | 3 +++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/evap/staff/forms.py b/evap/staff/forms.py index 0f59bab22f..46c1226de6 100644 --- a/evap/staff/forms.py +++ b/evap/staff/forms.py @@ -907,7 +907,15 @@ def save(self, commit=True): class QuestionForm(forms.ModelForm): class Meta: model = Question - fields = ("order", "questionnaire", "text_de", "text_en", "type", "allows_additional_textanswers", "counts_for_grade") + fields = ( + "order", + "questionnaire", + "text_de", + "text_en", + "type", + "allows_additional_textanswers", + "counts_for_grade", + ) widgets = { "text_de": forms.Textarea(attrs={"rows": 2}), "text_en": forms.Textarea(attrs={"rows": 2}), diff --git a/evap/student/forms.py b/evap/student/forms.py index 44b51e9eda..38e8306882 100644 --- a/evap/student/forms.py +++ b/evap/student/forms.py @@ -29,12 +29,14 @@ def from_question(cls, question: Question): class RatingAnswerField(forms.TypedChoiceField): - def __init__(self, widget_choices, *args, allows_textanswer=False, **kwargs): + def __init__(self, widget_choices, *args, allows_textanswer=False, counts_for_grade=True, **kwargs): self.allows_textanswer = allows_textanswer + self.counts_for_grade = counts_for_grade kwargs["coerce"] = int kwargs["widget"] = forms.RadioSelect( attrs={ "allows_textanswer": self.allows_textanswer, + "counts_for_grade": self.counts_for_grade, "choices": widget_choices, } ) @@ -47,6 +49,7 @@ def from_question(cls, question: Question): choices=zip(CHOICES[question.type].values, CHOICES[question.type].names, strict=True), label=question.text, allows_textanswer=question.allows_additional_textanswers, + counts_for_grade=question.counts_for_grade, ) diff --git a/evap/student/templates/student_vote_questionnaire_group.html b/evap/student/templates/student_vote_questionnaire_group.html index 348194cad9..8612d2dad5 100644 --- a/evap/student/templates/student_vote_questionnaire_group.html +++ b/evap/student/templates/student_vote_questionnaire_group.html @@ -35,6 +35,9 @@

+ {% if not field.field.widget.attrs.counts_for_grade %} + + {% endif %} {{ field.label }} {% if allows_textanswer %} From 68d227c3a04fa1c535d45d51c4687143f2f02d9b Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 26 May 2025 21:10:10 +0200 Subject: [PATCH 04/57] implement questions not being counted for grade calculation --- evap/results/tools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evap/results/tools.py b/evap/results/tools.py index 78280d7908..c7dcabedc7 100644 --- a/evap/results/tools.py +++ b/evap/results/tools.py @@ -324,7 +324,7 @@ def average_grade_questions_distribution(results): [ (unipolarized_distribution(result), result.count_sum) for result in results - if result.question.is_grade_question + if result.question.is_grade_question and result.question.counts_for_grade ] ) @@ -334,7 +334,7 @@ def average_non_grade_rating_questions_distribution(results): [ (unipolarized_distribution(result), result.count_sum) for result in results - if result.question.is_non_grade_rating_question + if result.question.is_non_grade_rating_question and result.question.counts_for_grade ] ) From 2a830cb8fd190387204aab446f442569641e4cc7 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 2 Jun 2025 22:00:39 +0200 Subject: [PATCH 05/57] add db contraint for counts_for_grade ensure text and heading never count for grade (current default in code with manual checking) --- evap/development/fixtures/test_data.json | 177 ++++++++++++++++++ .../0148_question_counts_for_grade.py | 23 +++ evap/evaluation/models.py | 7 +- .../fixtures/minimal_test_data_results.json | 5 + evap/results/tests/test_exporters.py | 1 + evap/results/tests/test_tools.py | 77 ++++++++ evap/staff/tests/test_views.py | 2 + 7 files changed, 291 insertions(+), 1 deletion(-) diff --git a/evap/development/fixtures/test_data.json b/evap/development/fixtures/test_data.json index 073ec62c7c..119fe1e2f6 100644 --- a/evap/development/fixtures/test_data.json +++ b/evap/development/fixtures/test_data.json @@ -20730,6 +20730,19 @@ "questionnaires": [] } }, +{ + "model": "evaluation.question", + "pk": 1, + "fields": { + "order": 1, + "questionnaire": 1, + "text_de": "Einzelergebnis", + "text_en": "Single result", + "allows_additional_textanswers": true, + "counts_for_grade": true, + "type": 2 + } +}, { "model": "evaluation.question", "pk": 255, @@ -20739,6 +20752,7 @@ "text_de": "Aus der Vorlesung habe ich etwas mitnehmen können.", "text_en": "I learned something in the lecture.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -20751,6 +20765,7 @@ "text_de": "Die in der Vorlesung vermittelten Techniken schienen aktuell.", "text_en": "The techniques taught in the lecture seemed to be up to date.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -20763,6 +20778,7 @@ "text_de": "Die Inhalte der Vorlesung bauten sinnvoll aufeinander auf.", "text_en": "The content of the lecture was well structured.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -20775,6 +20791,7 @@ "text_de": "Die Übung trug zu meinem Verständnis bei.", "text_en": "The exercise contributed to my comprehension of the lecture.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -20787,6 +20804,7 @@ "text_de": "Vorlesung und Übung waren gut aufeinander abgestimmt", "text_en": "Lecture and exercise were well synchronized.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -20799,6 +20817,7 @@ "text_de": "Das fachliche Niveau der Übung war angemessen.", "text_en": "The technical level of the exercise was appropriate.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -20811,6 +20830,7 @@ "text_de": "Diese Themenblöcke fand ich besonders interessant:", "text_en": "These topics were most interesting for me:", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -20823,6 +20843,7 @@ "text_de": "Diese Themenblöcke fand ich weniger interessant:", "text_en": "These topics were less interesting for me:", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -20835,6 +20856,7 @@ "text_de": "Es gab ausreichend Lehrmittel (Skripte, Folien, Videos, Bücher...)", "text_en": "The amount of provided learning material was sufficient.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -20847,6 +20869,7 @@ "text_de": "Die bereitgestellten Lehrmittel waren hilfreich.", "text_en": "The provided learning material was useful.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -20859,6 +20882,7 @@ "text_de": "Die Lehrmittel standen rechtzeitig zur Verfügung.", "text_en": "The learning material was provided in time.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -20871,6 +20895,7 @@ "text_de": "Was waren die Stärken und die Schwächen der Lehrmittel?", "text_en": "Which were the strengths and weaknesses of the learning material?", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -20883,6 +20908,7 @@ "text_de": "Die Vorlesung hat mir Spaß/ Freude bereitet.", "text_en": "I enjoyed the lecture.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -20895,6 +20921,7 @@ "text_de": "Ich bin zufrieden mit dem Lernerfolg.", "text_en": "I am satisfied with my learning success.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -20907,6 +20934,7 @@ "text_de": "Die Vorlesung hat mich in die Lage versetzt, das Thema selbständig zu vertiefen.", "text_en": "The lecture provided all information and skills that I need to deepen my knowledge in this topic.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -20919,6 +20947,7 @@ "text_de": "Die Vorlesung ist für mein Studium wichtig.", "text_en": "The lecture is important for my studies.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -20931,6 +20960,7 @@ "text_de": "Ich empfand den Zeitaufwand für die Veranstaltung insgesamt als angemessen.", "text_en": "I think the expenditure of time for the course was appropriate.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -20943,6 +20973,7 @@ "text_de": "Sonstige Kommentare", "text_en": "General remarks", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -20955,6 +20986,7 @@ "text_de": "... verhielt sich gegenüber den Studierenden respektvoll.", "text_en": "... showed respect for the students.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -20967,6 +20999,7 @@ "text_de": "... konnte mir Wissen vermitteln.", "text_en": "... imparted knowledge.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -20979,6 +21012,7 @@ "text_de": "... ging auf Fragen und Anregungen ein.", "text_en": "... adressed questions and proposals.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -20991,6 +21025,7 @@ "text_de": "... gestaltete die Veranstaltung interessant.", "text_en": "... gave the course in an interesting way.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21003,6 +21038,7 @@ "text_de": "... wirkte gut vorbereitet.", "text_en": "... seemed to be well prepared.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21015,6 +21051,7 @@ "text_de": "Wie bewertest du die Übung im Hinblick auf Umfang und Nutzen?", "text_en": "How do you evaluate the exercise concerning amount and usefulness?", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -21027,6 +21064,7 @@ "text_de": "Wie bewertest du die Schwierigkeit der Übung?", "text_en": "How do you evaluate the difficulty of the exercises?", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 6 } }, @@ -21039,6 +21077,7 @@ "text_de": "... verhielt sich gegenüber den Studierenden respektvoll.", "text_en": "... showed respect for the students.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21051,6 +21090,7 @@ "text_de": "... konnte mir Wissen vermitteln.", "text_en": "... imparted knowledge.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21063,6 +21103,7 @@ "text_de": "... stand auch außerhalb der regulären Übungstermine zur Verfügung.", "text_en": "... was available even outside the regular exercise meetings.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21075,6 +21116,7 @@ "text_de": "Das Seminar hat mir Spaß/Freude bereitet.", "text_en": "I enjoyed the seminar.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21087,6 +21129,7 @@ "text_de": "Die Seminarthemen fand ich gut ausgewählt.", "text_en": "The seminar's topics were chosen very well.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21099,6 +21142,7 @@ "text_de": "Die bereitgestellten Materialien waren gute Einstiegspunkte für eigene Recherche.", "text_en": "The provided learning material was a good start for own research.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21111,6 +21155,7 @@ "text_de": "Wie bewertest du den Aufwand des Seminars?", "text_en": "How do you evaluate the expenditure of time for the course?", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 8 } }, @@ -21123,6 +21168,7 @@ "text_de": "Aus dem Seminar habe ich etwas mitnehmen können.", "text_en": "I learned something from the seminar.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21135,6 +21181,7 @@ "text_de": "Ich kann nachvollziehen, wie und nach welchen Kriterien die Bewertung erfolgt.", "text_en": "I can understand how the grading is influenced by specific criteria.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21147,6 +21194,7 @@ "text_de": "Was hat das Seminar besonders ausgezeichnet?", "text_en": "What were the strengths of the seminar?", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -21159,6 +21207,7 @@ "text_de": "... verhielt sich gegenüber den Studierenden respektvoll.", "text_en": "... showed respect for the students.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21171,6 +21220,7 @@ "text_de": "... konnte mir Wissen vermitteln.", "text_en": "... imparted knowledge.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21183,6 +21233,7 @@ "text_de": "... ging auf Fragen und Anregungen ein.", "text_en": "... addressed questions and proposals.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21195,6 +21246,7 @@ "text_de": "... gestaltete das Seminar interessant.", "text_en": "... conducted the seminar in an interesting way.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21207,6 +21259,7 @@ "text_de": "... wirkte gut vorbereitet.", "text_en": "... seemed to be well prepared.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21219,6 +21272,7 @@ "text_de": "... stand auch außerhalb des Seminars zur Verfügung.", "text_en": "... was available even outside the seminar.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21231,6 +21285,7 @@ "text_de": "... hat mich/mein Team aktiv betreut.", "text_en": "... made a good job in actively supporting me/my team.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21243,6 +21298,7 @@ "text_de": "Das Projekt hat mir Spaß/Freude bereitet.", "text_en": "I enjoyed the project.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21255,6 +21311,7 @@ "text_de": "Ich empfand den Zeitaufwand für das Projekt als angemessen.", "text_en": "I think the expenditure of time for the project was appropriate.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21267,6 +21324,7 @@ "text_de": "Aus dem Projekt habe ich etwas mitnehmen können.", "text_en": "I learned something from the project.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21279,6 +21337,7 @@ "text_de": "... verhielt sich gegenüber den Studierenden respektvoll.", "text_en": "... showed respect for the students.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21291,6 +21350,7 @@ "text_de": "... konnte mir Wissen vermitteln.", "text_en": "... imparted knowledge.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21303,6 +21363,7 @@ "text_de": "... ging auf Fragen und Anregungen ein.", "text_en": "... addressed questions and proposals.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21315,6 +21376,7 @@ "text_de": "... hat mich/mein Team aktiv betreut.", "text_en": "... made a good job in actively supervising me/my team.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21327,6 +21389,7 @@ "text_de": "... wirkte gut vorbereitet.", "text_en": "... seemed to be well prepared.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21339,6 +21402,7 @@ "text_de": "Hier kannst du Lob und Kritik an den Verantwortlichen der Lehrveranstaltung loswerden. Bitte sei höflich und konstruktiv!", "text_en": "Please give your feedback to the responsible person here. Be polite and constructive!", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -21351,6 +21415,7 @@ "text_de": "... verhielt sich gegenüber den Studierenden respektvoll.", "text_en": "... showed respect for the students.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21363,6 +21428,7 @@ "text_de": "... konnte mir Wissen vermitteln.", "text_en": "... could equip me with knowledge.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21375,6 +21441,7 @@ "text_de": "... ging auf Fragen und Anregungen ein.", "text_en": "... addressed questions and proposals.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21387,6 +21454,7 @@ "text_de": "... gestaltete die Übung interessant.", "text_en": "... conducted the excercise in an interesting way.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21399,6 +21467,7 @@ "text_de": "... wirkte gut vorbereitet.", "text_en": "... seemed to be well-prepared.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21411,6 +21480,7 @@ "text_de": "Hier kannst du Lob und Kritik zur Person loswerden. Bitte sei höflich und konstruktiv!", "text_en": "Please give your feedback to the person here. Be polite and constructive!", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -21423,6 +21493,7 @@ "text_de": "Hier kannst du Lob und Kritik zu weiteren Mitwirkenden loswerden. Bitte sei höflich und konstruktiv!", "text_en": "Please give your feedback to additional contributors here. Be polite and constructive!", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -21435,6 +21506,7 @@ "text_de": "... ging auf Fragen und Anregungen ein.", "text_en": "... addressed questions and proposals.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21447,6 +21519,7 @@ "text_de": "Ich kann nachvollziehen, wie und nach welchen Kriterien die Bewertung erfolgt.", "text_en": "I can understand how the grading is influenced by specific criteria.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21459,6 +21532,7 @@ "text_de": "... stand auch außerhalb der Veranstaltung zur Verfügung.", "text_en": "... was available even outside the course.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21471,6 +21545,7 @@ "text_de": "... stand auch außerhalb der regulären Termine zur Verfügung.", "text_en": "... was avalable even outside the regular meetings.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21483,6 +21558,7 @@ "text_de": "... stand nur selten zur Verfügung.", "text_en": "... was rarely available.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 12 } }, @@ -21495,6 +21571,7 @@ "text_de": "... wirkte gut vorbereitet.", "text_en": "... seemed to be well prepared.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21507,6 +21584,7 @@ "text_de": "Ich kann nachvollziehen, wie und nach welchen Kriterien die Bewertung erfolgt.", "text_en": "I can understand how the grading is influenced by specific criteria.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21519,6 +21597,7 @@ "text_de": "Durch welche Änderungen könnte man das Seminar noch verbessern?", "text_en": "How could the seminar be further improved?", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -21531,6 +21610,7 @@ "text_de": "Das Projekt hat mir Spaß/Freude bereitet.", "text_en": "I enjoyed the project.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21543,6 +21623,7 @@ "text_de": "Das Projekt passt inhaltlich in das bisherige Studium.", "text_en": "The project's topic fits into my studies.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21555,6 +21636,7 @@ "text_de": "Das Projektthema war interessant.", "text_en": "The project's topic was interesting.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21567,6 +21649,7 @@ "text_de": "Das Projektthema passte zu meinen Interessen und Fähigkeiten.", "text_en": "The project matched my interests and skills.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21579,6 +21662,7 @@ "text_de": "Das Projekt entsprach meiner Vorstellung.", "text_en": "The project met my expectations.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21591,6 +21675,7 @@ "text_de": "Die Aufgabenstellung war klar spezifiziert.", "text_en": "The tasks were clearly specified.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21603,6 +21688,7 @@ "text_de": "Bei einer wiederholten Wahl würde ich das gleiche Projekt wählen.", "text_en": "I would choose the same project again.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21615,6 +21701,7 @@ "text_de": "Was hat dir am Projekt besonders gefallen? Wie hätte man das Projekt besser gestalten können?", "text_en": "What was the best part of the project? What could be improved?", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -21627,6 +21714,7 @@ "text_de": "Wie bewertest du den Zeitaufwand für das Projekt?", "text_en": "How do you evaluate the expenditure of time for the project?", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 8 } }, @@ -21639,6 +21727,7 @@ "text_de": "Es haben sich alle Teilnehmer ausgeglichen beteiligt.", "text_en": "All team members participated equally.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21651,6 +21740,7 @@ "text_de": "Die Zeitvorgaben für die Teil-Aufgaben waren realistisch.", "text_en": "The deadlines for sub-task were realistic.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21663,6 +21753,7 @@ "text_de": "Es stand ausreichend Zeit zur Ausarbeitung von Pressemitteilung, Plakat und Präsentation zur Verfügung.", "text_en": "The time for preparation of the press release, poster and presentation was sufficient.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21675,6 +21766,7 @@ "text_de": "Sonstige Kommentare zu Aufwand und Zeitplanung:", "text_en": "Additional remarks on effort and time management:", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -21687,6 +21779,7 @@ "text_de": "Der Professor zeigte Interesse am Projekt und dessen Ergebnis.", "text_en": "The professor was interested in the project and its results.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21699,6 +21792,7 @@ "text_de": "Der Professor hat sich während des Projektzeitraumes eingebracht.", "text_en": "The professor contributed to the project.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21711,6 +21805,7 @@ "text_de": "Der Projektpartner hat sich während des Projektzeitraumes eingebracht.", "text_en": "The business partner contributed to the project.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21723,6 +21818,7 @@ "text_de": "Die Betreuung durch den Lehrstuhl war zufriedenstellend.", "text_en": "The supervision by the chair was satisfying.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21735,6 +21831,7 @@ "text_de": "Die Betreuung durch den Projektpartner war zufriedenstellend.", "text_en": "The supervision by the business project partner was satisfying.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21747,6 +21844,7 @@ "text_de": "Sonstige Kommentare zur Betreuung und zum Projektpartner:", "text_en": "Additional remarks on supervision and the business partner:", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -21759,6 +21857,7 @@ "text_de": "Mein Bachelorarbeitsthema war interessant.", "text_en": "The topic of my bachelor thesis was interesting.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21771,6 +21870,7 @@ "text_de": "Mein Bachelorarbeitsthema passte zu meinen Vertiefungsgebieten.", "text_en": "The topic of my bachelor's thesis matched my areas of specialization.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21783,6 +21883,7 @@ "text_de": "Es war leicht, aus dem Projektthema Themen für die Bachelorarbeiten abzuleiten.", "text_en": "It was easy to derive the topics of the bachelor's theses out of the project's scope.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21795,6 +21896,7 @@ "text_de": "Das Thema meiner Bachelorarbeit war inhaltlich nahe am Projekt.", "text_en": "The topic of my bachelor's thesis was directly derived from the scope of the project.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21807,6 +21909,7 @@ "text_de": "Es stand ausreichend Zeit zur Ausarbeitung der Bachelorarbeit zur Verfügung.", "text_en": "The time for the preparation of my bachelor's thesis was sufficient.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21819,6 +21922,7 @@ "text_de": "Sonstige Kommentare zur Bachelorarbeit:", "text_en": "Additional remarks on the bachelor's thesis:", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -21831,6 +21935,7 @@ "text_de": "... verhielt sich gegenüber den Studierenden respektvoll.", "text_en": "... showed respect for the students.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21843,6 +21948,7 @@ "text_de": "... konnte mir Wissen vermitteln.", "text_en": "... imparted knowledge.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21855,6 +21961,7 @@ "text_de": "... ging auf Fragen und Anregungen ein.", "text_en": "... adressed questions and proposals.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21867,6 +21974,7 @@ "text_de": "... gestaltete die Veranstaltung interessant.", "text_en": "... gave the course in an interesting way.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21879,6 +21987,7 @@ "text_de": "Das Projekt hat mir Spaß/Freude bereitet.", "text_en": "I enjoyed the project.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21891,6 +22000,7 @@ "text_de": "Das Projekt passt inhaltlich in das bisherige Studium.", "text_en": "The project's topic fits into my studies.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21903,6 +22013,7 @@ "text_de": "Das Projektthema war interessant.", "text_en": "The project's topic was interesting.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21915,6 +22026,7 @@ "text_de": "Das Projektthema passte zu meinen Interessen und Fähigkeiten.", "text_en": "The project matched my interests and skills.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21927,6 +22039,7 @@ "text_de": "Das Projekt entsprach meiner Vorstellung.", "text_en": "The project met my expectations.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21939,6 +22052,7 @@ "text_de": "Die Aufgabenstellung war klar spezifiziert.", "text_en": "The tasks were clearly specified.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21951,6 +22065,7 @@ "text_de": "Bei einer wiederholten Wahl würde ich das gleiche Projekt wählen.", "text_en": "I would choose the same project again.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21963,6 +22078,7 @@ "text_de": "Was hat dir am Projekt besonders gefallen? Wie hätte man das Projekt besser gestalten können?", "text_en": "What was the best part of the project? What could be improved?", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -21975,6 +22091,7 @@ "text_de": "Wie bewertest du den Zeitaufwand?", "text_en": "How do you evaluate the expenditure of time?", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 8 } }, @@ -21987,6 +22104,7 @@ "text_de": "Es haben sich alle Teilnehmer ausgeglichen beteiligt.", "text_en": "All team members participated equally.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -21999,6 +22117,7 @@ "text_de": "Die Zeitvorgaben für die Teil-Aufgaben waren realistisch.", "text_en": "The deadlines for sub-tasks were realistic.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22011,6 +22130,7 @@ "text_de": "Sonstige Kommentare zu Aufwand und Zeitplanung:", "text_en": "Additional remarks on effort and time management:", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -22023,6 +22143,7 @@ "text_de": "Der Professor zeigte Interesse am Projekt und dessen Ergebnis.", "text_en": "The professor was interested in the project and its results.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22035,6 +22156,7 @@ "text_de": "Der Professor hat sich während des Projektzeitraumes eingebracht.", "text_en": "The professor contributed to the project.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22047,6 +22169,7 @@ "text_de": "Die Betreuung durch den Lehrstuhl war zufriedenstellend.", "text_en": "The supervision by the chair was satisfying.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22059,6 +22182,7 @@ "text_de": "Sonstige Kommentare zur Betreuung:", "text_en": "Additional remarks on supervision:", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -22071,6 +22195,7 @@ "text_de": "Mein Masterarbeitsthema war interessant.", "text_en": "The topic of my master's thesis was interesting.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22083,6 +22208,7 @@ "text_de": "Mein Masterarbeitsthema passte zu meinen Vertiefungsgebieten.", "text_en": "The topic of my master thesis matched my areas of specialization.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22095,6 +22221,7 @@ "text_de": "Es stand ausreichend Zeit zur Ausarbeitung der Masterarbeit zur Verfügung.", "text_en": "The time for the preparation of my master's thesis was sufficient.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22107,6 +22234,7 @@ "text_de": "Sonstige Kommentare zur Masterarbeit:", "text_en": "Additional remarks on the master's thesis:", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -22119,6 +22247,7 @@ "text_de": "... konnte Sachverhalte sehr gut erklären.", "text_en": "... did explain issues very well.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22131,6 +22260,7 @@ "text_de": "Was hat das Projekt besonders ausgezeichnet?", "text_en": "What were the strengths of the project?", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -22143,6 +22273,7 @@ "text_de": "Durch welche Änderungen könnte man das Projekt noch verbessern?", "text_en": "How could the project be further improved?", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -22155,6 +22286,7 @@ "text_de": "... wirkte gut vorbereitet.", "text_en": "... seemed to be well prepared.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22167,6 +22299,7 @@ "text_de": "Das Projektseminar hat mir Spaß/Freude bereitet.", "text_en": "I enjoyed the project seminar.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22179,6 +22312,7 @@ "text_de": "Die Seminarthemen fand ich gut ausgewählt.", "text_en": "The seminar's topics were chosen very well.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22191,6 +22325,7 @@ "text_de": "Die bereitgestellten Materialien waren gute Einstiegspunkte für eigene Recherche.", "text_en": "The provided learning material was a good start for own research.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22203,6 +22338,7 @@ "text_de": "Ich empfand den Aufwand für das Projektseminar als angemessen.", "text_en": "I think the expenditure of time for the project seminar was appropriate.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22215,6 +22351,7 @@ "text_de": "Aus dem Projektseminar habe ich etwas mitnehmen können.", "text_en": "I learned something from the project seminar.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22227,6 +22364,7 @@ "text_de": "Ich kann nachvollziehen, wie und nach welchen Kriterien die Bewertung erfolgt.", "text_en": "I can understand how the grading is influenced by specific criteria.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22239,6 +22377,7 @@ "text_de": "Die theoretischen Inhalte des Seminars haben mir bei der Bearbeitung des Projekts geholfen.", "text_en": "The theoretical contents of the seminar helped me working on the project.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22251,6 +22390,7 @@ "text_de": "Was hat das Projektseminar besonders ausgezeichnet?", "text_en": "What were the strengths of the project seminar?", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -22263,6 +22403,7 @@ "text_de": "Durch welche Änderungen könnte man das Projektseminar noch verbessern?", "text_en": "How could the project seminar be further improved?", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -22275,6 +22416,7 @@ "text_de": "Das Seminar hat mir Spaß/Freude bereitet.", "text_en": "I enjoyed the seminar.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22287,6 +22429,7 @@ "text_de": "Das Projektthema fand ich gut ausgewählt.", "text_en": "The project topic was chosen very well.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22299,6 +22442,7 @@ "text_de": "Ich empfand den Aufwand für das Seminar als angemessen.", "text_en": "I think the expenditure of time for the seminar was appropriate.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22311,6 +22455,7 @@ "text_de": "Aus dem Seminar habe ich etwas mitnehmen können.", "text_en": "I learned something from the seminar.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22323,6 +22468,7 @@ "text_de": "Ich kann mir vorstellen, Ansätze und Methoden aus dem Design Thinking auch bei zukünftigen Projekten anzuwenden.", "text_en": "I can imagine using design thinking principles and methods in future projects.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22335,6 +22481,7 @@ "text_de": "Das Seminar ist eine Bereicherung für meinen Studiengang.", "text_en": "The seminar is a good additional contribution to my major subject. ", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22347,6 +22494,7 @@ "text_de": "Was hat das Seminar besonders ausgezeichnet?", "text_en": "What were the strengths of the seminar?", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -22359,6 +22507,7 @@ "text_de": "Durch welche Änderungen könnte man das Seminar noch verbessern?", "text_en": "How could the seminar be further improved?", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -22371,6 +22520,7 @@ "text_de": "Das Studienbegleitende Seminar hat mir Spaß/Freude bereitet.", "text_en": "I enjoyed the accompanying seminar.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22383,6 +22533,7 @@ "text_de": "Die Vorträge fand ich gut ausgewählt.", "text_en": "The topics of the talks were chosen very well.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22395,6 +22546,7 @@ "text_de": "Ich empfand den Aufwand für das Studienbegleitende Seminar als angemessen.", "text_en": "I think the expenditure of time for the accompanying seminar was appropriate.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22407,6 +22559,7 @@ "text_de": "Aus dem Studienbegleitenden Seminar habe ich etwas mitnehmen können.", "text_en": "I learned something from the accompanying seminar.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22419,6 +22572,7 @@ "text_de": "Das Studienbegleitende Seminar hat mir beim Einstieg in das Studium geholfen.", "text_en": "The accompanying seminar helped me to get started with my studies.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22431,6 +22585,7 @@ "text_de": "Was hat das Studienbegleitende Seminar besonders ausgezeichnet?", "text_en": "What were the strengths of the accompanying seminar?", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -22443,6 +22598,7 @@ "text_de": "Durch welche Änderungen könnte man das Studienbegleitende Seminar noch verbessern?", "text_en": "How could the accompanying seminar be further improved?", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -22455,6 +22611,7 @@ "text_de": "... verhielt sich gegenüber den Studierenden respektvoll.", "text_en": "... showed respect for the students.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22467,6 +22624,7 @@ "text_de": "... konnte mir Wissen vermitteln.", "text_en": "... imparted knowledge.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22479,6 +22637,7 @@ "text_de": "... ging auf Fragen und Anregungen ein.", "text_en": "... addressed questions and proposals.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22491,6 +22650,7 @@ "text_de": "... gestaltete das Seminar interessant.", "text_en": "... conducted the seminar in an interesting way.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22503,6 +22663,7 @@ "text_de": "... wirkte gut vorbereitet.", "text_en": "... seemed to be well prepared.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22515,6 +22676,7 @@ "text_de": "... hat den Seminarablauf gut gestaltet.", "text_en": "... created a good structure of the seminar.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22527,6 +22689,7 @@ "text_de": "... hat mich/mein Team aktiv betreut.", "text_en": "... made a good job in actively supporting me/my team.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22539,6 +22702,7 @@ "text_de": "... stand auch außerhalb des Seminars zur Verfügung.", "text_en": "... was available even outside the seminar.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22551,6 +22715,7 @@ "text_de": "... verhielt sich gegenüber den Studierenden respektvoll.", "text_en": "... showed respect for the students.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22563,6 +22728,7 @@ "text_de": "... konnte mir Wissen vermitteln.", "text_en": "... imparted knowledge.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22575,6 +22741,7 @@ "text_de": "... ging auf Fragen und Anregungen ein.", "text_en": "... addressed questions and proposals.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22587,6 +22754,7 @@ "text_de": "... wirkte gut vorbereitet.", "text_en": "... seemed to be well prepared.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22599,6 +22767,7 @@ "text_de": "... hat mich/mein Team aktiv betreut.", "text_en": "... made a good job in actively supporting me/my team.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22611,6 +22780,7 @@ "text_de": "... stand auch außerhalb des Seminars zur Verfügung.", "text_en": "... was available even outside the seminar.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -22623,6 +22793,7 @@ "text_de": "Wie würdest du die Veranstaltung insgesamt bewerten?", "text_en": "How would you grade the course in total?", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 2 } }, @@ -22635,6 +22806,7 @@ "text_de": "Würden Sie eine Fortsetzung des Kurses besuchen?", "text_en": "Would you enroll in a continued version of this course?", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 3 } }, @@ -22647,6 +22819,7 @@ "text_de": "Fanden Sie den Kurs zu schwer?", "text_en": "Was the course too difficult?", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 6 } }, @@ -22659,6 +22832,7 @@ "text_de": "Wie empfandest du die Größe des Seminars?", "text_en": "How do you feel about the size of the seminar?", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 9 } }, @@ -22671,6 +22845,7 @@ "text_de": "Es gab genügend Übungen.", "text_en": "There were sufficient exercises.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 7 } }, @@ -22683,6 +22858,7 @@ "text_de": "... ist in einem angenehmen Tempo vorangeschritten.", "text_en": "... had a comfortable pace.", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 10 } }, @@ -22695,6 +22871,7 @@ "text_de": "Wie fandest du die Klausur?", "text_en": "How did you like the exam?", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 6 } }, diff --git a/evap/evaluation/migrations/0148_question_counts_for_grade.py b/evap/evaluation/migrations/0148_question_counts_for_grade.py index 1efcdcfd3a..dd2932a5bd 100644 --- a/evap/evaluation/migrations/0148_question_counts_for_grade.py +++ b/evap/evaluation/migrations/0148_question_counts_for_grade.py @@ -3,6 +3,17 @@ from django.db import migrations, models +def set_initial_values(apps, _schema_editor): + TEXT = 0 + HEADING = 5 + + Question = apps.get_model('evaluation', 'Question') + for question in Question.objects.all(): + if question.type in [TEXT, HEADING]: + question.counts_for_grade = False + question.save() + + class Migration(migrations.Migration): dependencies = [ @@ -15,4 +26,16 @@ class Migration(migrations.Migration): name="counts_for_grade", field=models.BooleanField(default=True, verbose_name="counts toward the evaluations grade"), ), + migrations.RunPython(set_initial_values, reverse_code=migrations.RunPython.noop), + migrations.AddConstraint( + model_name="question", + constraint=models.CheckConstraint( + condition=models.Q( + models.Q(("type", 0), ("type", 5), _connector="OR", _negated=True), + ("counts_for_grade", False), + _connector="OR", + ), + name="check_evaluation_textanswer_or_heading_question_does_not_count_for_grade", + ), + ), ] diff --git a/evap/evaluation/models.py b/evap/evaluation/models.py index 10a7dc68bd..387304d5a4 100644 --- a/evap/evaluation/models.py +++ b/evap/evaluation/models.py @@ -1283,12 +1283,17 @@ class Meta: ~(Q(type=QuestionType.TEXT) | Q(type=QuestionType.HEADING)) | ~Q(allows_additional_textanswers=True) ), name="check_evaluation_textanswer_or_heading_question_has_no_additional_textanswers", - ) + ), + CheckConstraint( + condition=(~(Q(type=QuestionType.TEXT) | Q(type=QuestionType.HEADING)) | Q(counts_for_grade=False)), + name="check_evaluation_textanswer_or_heading_question_does_not_count_for_grade", + ), ] def save(self, *args, **kwargs): if self.type in [QuestionType.TEXT, QuestionType.HEADING]: self.allows_additional_textanswers = False + self.counts_for_grade = False if "update_fields" in kwargs: kwargs["update_fields"] = {"allows_additional_textanswers", "counts_for_grade"}.union( kwargs["update_fields"] diff --git a/evap/results/fixtures/minimal_test_data_results.json b/evap/results/fixtures/minimal_test_data_results.json index 81ff276acf..5e723a3e30 100644 --- a/evap/results/fixtures/minimal_test_data_results.json +++ b/evap/results/fixtures/minimal_test_data_results.json @@ -189,6 +189,7 @@ "text_de": "how?", "text_en": "how?", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -201,6 +202,7 @@ "text_de": "how much?", "text_en": "how much?", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -213,6 +215,7 @@ "text_de": "how?", "text_en": "how?", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -225,6 +228,7 @@ "text_de": "how much?", "text_en": "how much?", "allows_additional_textanswers": true, + "counts_for_grade": true, "type": 1 } }, @@ -237,6 +241,7 @@ "text_de": "your grade", "text_en": "your grade", "allows_additional_textanswers": false, + "counts_for_grade": true, "type": 2 } }, diff --git a/evap/results/tests/test_exporters.py b/evap/results/tests/test_exporters.py index a33c399657..2be5414eed 100644 --- a/evap/results/tests/test_exporters.py +++ b/evap/results/tests/test_exporters.py @@ -544,6 +544,7 @@ def test_text_answer_export(self): _quantity=len(Questionnaire.Type.values), _bulk_create=True, allows_additional_textanswers=False, + counts_for_grade=False, ) baker.make( diff --git a/evap/results/tests/test_tools.py b/evap/results/tests/test_tools.py index 40608198b2..4faed79183 100644 --- a/evap/results/tests/test_tools.py +++ b/evap/results/tests/test_tools.py @@ -20,6 +20,8 @@ from evap.results.tools import ( ViewContributorResults, ViewGeneralResults, + average_grade_questions_distribution, + average_non_grade_rating_questions_distribution, cache_results, calculate_average_course_distribution, calculate_average_distribution, @@ -436,6 +438,81 @@ def test_dropout_questionnaires_are_not_included(self): calculated_grade = distribution_to_grade(calculate_average_distribution(self.evaluation)) self.assertAlmostEqual(calculated_grade, 1.5) + def test_grade_calculation_with_non_counting_questions(self): + non_counting_question = baker.make( + Question, questionnaire=self.questionnaire, type=QuestionType.GRADE, counts_for_grade=False + ) + + counters = [ + *make_rating_answer_counters(self.question_grade, self.contribution1, [1, 0, 0, 0, 0], False), + *make_rating_answer_counters(non_counting_question, self.contribution1, [0, 0, 0, 0, 1], False), + ] + RatingAnswerCounter.objects.bulk_create(counters) + + cache_results(self.evaluation) + + average_grade = distribution_to_grade(calculate_average_distribution(self.evaluation)) + + self.assertAlmostEqual(average_grade, 1.0) + + def test_average_questions_distribution(self): + grade_question = baker.make( + Question, questionnaire=self.questionnaire, type=QuestionType.GRADE, counts_for_grade=True + ) + non_counting_grade_question = baker.make( + Question, questionnaire=self.questionnaire, type=QuestionType.GRADE, counts_for_grade=False + ) + likert_question = baker.make( + Question, questionnaire=self.questionnaire, type=QuestionType.POSITIVE_LIKERT, counts_for_grade=True + ) + non_counting_likert_question = baker.make( + Question, questionnaire=self.questionnaire, type=QuestionType.POSITIVE_LIKERT, counts_for_grade=False + ) + + counters = [ + *make_rating_answer_counters(grade_question, self.contribution1, [1, 0, 0, 0, 0], False), + *make_rating_answer_counters(non_counting_grade_question, self.contribution1, [0, 0, 0, 0, 1], False), + *make_rating_answer_counters(likert_question, self.contribution1, [0, 0, 3, 0, 0], False), + *make_rating_answer_counters(non_counting_likert_question, self.contribution1, [0, 0, 0, 0, 3], False), + ] + RatingAnswerCounter.objects.bulk_create(counters) + + cache_results(self.evaluation) + evaluation_results = get_results(self.evaluation) + + question_results = [] + for contribution_result in evaluation_results.contribution_results: + for questionnaire_result in contribution_result.questionnaire_results: + question_results.extend(questionnaire_result.question_results) + + grade_distribution = average_grade_questions_distribution(question_results) + self.assertIsNotNone(grade_distribution) + self.assertEqual(grade_distribution[0], 1.0) # Only the counting grade question should be included + self.assertEqual(sum(grade_distribution[1:]), 0.0) + + non_grade_distribution = average_non_grade_rating_questions_distribution(question_results) + self.assertIsNotNone(non_grade_distribution) + self.assertEqual(non_grade_distribution[2], 1.0) # Only the counting likert question should be included + self.assertEqual(sum(non_grade_distribution[:2] + non_grade_distribution[3:]), 0.0) + + RatingAnswerCounter.objects.all().delete() + counters = [ + *make_rating_answer_counters(non_counting_grade_question, self.contribution1, [0, 0, 0, 0, 1], False), + *make_rating_answer_counters(non_counting_likert_question, self.contribution1, [0, 0, 0, 0, 3], False), + ] + RatingAnswerCounter.objects.bulk_create(counters) + + cache_results(self.evaluation) + evaluation_results = get_results(self.evaluation) + + question_results = [] + for contribution_result in evaluation_results.contribution_results: + for questionnaire_result in contribution_result.questionnaire_results: + question_results.extend(questionnaire_result.question_results) + + self.assertIsNone(average_grade_questions_distribution(question_results)) + self.assertIsNone(average_non_grade_rating_questions_distribution(question_results)) + class TestTextAnswerVisibilityInfo(TestCase): @classmethod diff --git a/evap/staff/tests/test_views.py b/evap/staff/tests/test_views.py index 0902f69a18..e4bfe778fc 100644 --- a/evap/staff/tests/test_views.py +++ b/evap/staff/tests/test_views.py @@ -3010,6 +3010,7 @@ def test_num_queries_is_constant(self): questionnaire=iter(questionnaires), type=QuestionType.TEXT, allows_additional_textanswers=False, + counts_for_grade=False, **kwargs, ) baker.make(TextAnswer, question=iter(questions), contribution=iter(contributions), **kwargs) @@ -3323,6 +3324,7 @@ def setUpTestData(cls): _quantity=3, _bulk_create=True, allows_additional_textanswers=False, + counts_for_grade=iter([False, True, True]), ) def test_preview_change_language(self): From 15887a1a9458b67dda90a354dba100bcf62eea2f Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 16 Jun 2025 21:13:23 +0200 Subject: [PATCH 06/57] add tests to improve coverage test if for question and text type allows_additional_textanswers and counts_for_grade are set to their defaults and are always included for saving --- evap/evaluation/tests/test_models.py | 35 ++++++++++++++++++++++++++++ evap/staff/tests/test_forms.py | 19 +++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/evap/evaluation/tests/test_models.py b/evap/evaluation/tests/test_models.py index eb4f82b19d..c3dce344f1 100644 --- a/evap/evaluation/tests/test_models.py +++ b/evap/evaluation/tests/test_models.py @@ -5,6 +5,7 @@ from django.core import mail from django.core.cache import caches from django.core.exceptions import ValidationError +from django.db import models from django.test import override_settings from django_fsm import TransitionNotAllowed from model_bakery import baker @@ -1146,3 +1147,37 @@ class QuestionnaireTests(TestCase): def test_locked_contributor_questionnaire(self): questionnaire = baker.prepare(Questionnaire, is_locked=True, type=Questionnaire.Type.CONTRIBUTOR) self.assertRaises(ValidationError, questionnaire.clean) + + +class QuestionTests(TestCase): + def test_save_for_text_and_heading_question_type(self): + questionaire = baker.make(Questionnaire) + question_text = baker.prepare( + Question, + questionnaire=questionaire, + type=QuestionType.TEXT, + allows_additional_textanswers=True, + counts_for_grade=True, + ) + question_heading = baker.prepare( + Question, + questionnaire=questionaire, + type=QuestionType.HEADING, + allows_additional_textanswers=True, + counts_for_grade=True, + ) + + with patch.object(models.Model, "save") as mock_save: + question_text.save(update_fields=["text_de"]) + mock_save.assert_called_once() + args, kwargs = mock_save.call_args + self.assertEqual( + set(kwargs["update_fields"]), {"allows_additional_textanswers", "counts_for_grade", "text_de"} + ) + + question_heading.save(update_fields=["text_de"]) + self.assertEqual(mock_save.call_count, 2) + args, kwargs = mock_save.call_args + self.assertEqual( + set(kwargs["update_fields"]), {"allows_additional_textanswers", "counts_for_grade", "text_de"} + ) diff --git a/evap/staff/tests/test_forms.py b/evap/staff/tests/test_forms.py index 9846e7cadc..c1a939b79c 100644 --- a/evap/staff/tests/test_forms.py +++ b/evap/staff/tests/test_forms.py @@ -36,6 +36,7 @@ EvaluationCopyForm, EvaluationEmailForm, EvaluationForm, + QuestionForm, QuestionnaireForm, UserForm, ) @@ -1149,3 +1150,21 @@ def test_save_makes_a_copy(self): copied_evaluation = form.save() self.assertNotEqual(copied_evaluation, self.evaluation) self.assertEqual(Evaluation.objects.count(), 2) + + +class QuestionFormTests(TestCase): + def test_fields_disabled_for_text_and_heading(self): + question = baker.make(Question, type=QuestionType.TEXT) + form = QuestionForm(instance=question) + self.assertTrue(form.fields["allows_additional_textanswers"].widget.attrs.get("disabled")) + self.assertTrue(form.fields["counts_for_grade"].widget.attrs.get("disabled")) + + question = baker.make(Question, type=QuestionType.HEADING) + form = QuestionForm(instance=question) + self.assertTrue(form.fields["allows_additional_textanswers"].widget.attrs.get("disabled")) + self.assertTrue(form.fields["counts_for_grade"].widget.attrs.get("disabled")) + + question = baker.make(Question, type=QuestionType.POSITIVE_LIKERT) + form = QuestionForm(instance=question) + self.assertFalse(form.fields["allows_additional_textanswers"].widget.attrs.get("disabled")) + self.assertFalse(form.fields["counts_for_grade"].widget.attrs.get("disabled")) From a78c9b91e59762ffffa88d001d728f8643917f2b Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 16 Jun 2025 22:30:52 +0200 Subject: [PATCH 07/57] minor review changes remove spaces from title render all poosibly defined widget attrs of addional textanswers and counts for grade --- evap/staff/templates/staff_questionnaire_form.html | 8 ++++++-- .../templates/student_vote_questionnaire_group.html | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/evap/staff/templates/staff_questionnaire_form.html b/evap/staff/templates/staff_questionnaire_form.html index 05010e41e4..f03a94d89a 100644 --- a/evap/staff/templates/staff_questionnaire_form.html +++ b/evap/staff/templates/staff_questionnaire_form.html @@ -58,13 +58,17 @@
{% translate 'Questions' %}
+ {% for attr_name, attr_value in form_element.allows_additional_textanswers.field.widget.attrs.items %} + {{ attr_name }}="{{ attr_value }}" + {% endfor %} />
+ {% for attr_name, attr_value in form_element.allows_additional_textanswers.field.widget.attrs.items %} + {{ attr_name }}="{{ attr_value }}" + {% endfor %} />
diff --git a/evap/student/templates/student_vote_questionnaire_group.html b/evap/student/templates/student_vote_questionnaire_group.html index 8612d2dad5..1d2250ede1 100644 --- a/evap/student/templates/student_vote_questionnaire_group.html +++ b/evap/student/templates/student_vote_questionnaire_group.html @@ -36,7 +36,7 @@

{% if not field.field.widget.attrs.counts_for_grade %} - + {% endif %} {{ field.label }} From 374c48b2c44ad16c2ef8edd0c61cb94f6206f585 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 14 Jul 2025 20:23:59 +0200 Subject: [PATCH 08/57] more minor review changes --- evap/evaluation/tests/test_models.py | 3 ++- evap/staff/templates/staff_questionnaire_form.html | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/evap/evaluation/tests/test_models.py b/evap/evaluation/tests/test_models.py index c3dce344f1..7e33b9e5bd 100644 --- a/evap/evaluation/tests/test_models.py +++ b/evap/evaluation/tests/test_models.py @@ -1175,8 +1175,9 @@ def test_save_for_text_and_heading_question_type(self): set(kwargs["update_fields"]), {"allows_additional_textanswers", "counts_for_grade", "text_de"} ) + mock_save.reset_mock() question_heading.save(update_fields=["text_de"]) - self.assertEqual(mock_save.call_count, 2) + mock_save.assert_called_once() args, kwargs = mock_save.call_args self.assertEqual( set(kwargs["update_fields"]), {"allows_additional_textanswers", "counts_for_grade", "text_de"} diff --git a/evap/staff/templates/staff_questionnaire_form.html b/evap/staff/templates/staff_questionnaire_form.html index f03a94d89a..3433878277 100644 --- a/evap/staff/templates/staff_questionnaire_form.html +++ b/evap/staff/templates/staff_questionnaire_form.html @@ -66,9 +66,7 @@
{% translate 'Questions' %}
+ {% if 'disabled' in form_element.counts_for_grade.field.widget.attrs %} disabled{% endif %} />
From 5ffe1e96ca25608ee430cf8e1d5cb6d237b452fd Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 14 Jul 2025 20:39:08 +0200 Subject: [PATCH 09/57] fix migration naming --- ...on_counts_for_grade.py => 0149_question_counts_for_grade.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename evap/evaluation/migrations/{0148_question_counts_for_grade.py => 0149_question_counts_for_grade.py} (95%) diff --git a/evap/evaluation/migrations/0148_question_counts_for_grade.py b/evap/evaluation/migrations/0149_question_counts_for_grade.py similarity index 95% rename from evap/evaluation/migrations/0148_question_counts_for_grade.py rename to evap/evaluation/migrations/0149_question_counts_for_grade.py index dd2932a5bd..6eaa6a9d4d 100644 --- a/evap/evaluation/migrations/0148_question_counts_for_grade.py +++ b/evap/evaluation/migrations/0149_question_counts_for_grade.py @@ -17,7 +17,7 @@ def set_initial_values(apps, _schema_editor): class Migration(migrations.Migration): dependencies = [ - ("evaluation", "0147_unusable_password_default"), + ("evaluation", "0148_course_cms_id_evaluation_cms_id"), ] operations = [ From 45e373bbd521b2eed9518a72fe78641507722b5d Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Fri, 18 Jul 2025 10:11:46 +0200 Subject: [PATCH 10/57] coding style --- evap/evaluation/migrations/0149_question_counts_for_grade.py | 5 +---- evap/evaluation/models.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/evap/evaluation/migrations/0149_question_counts_for_grade.py b/evap/evaluation/migrations/0149_question_counts_for_grade.py index 6eaa6a9d4d..8c39292dfe 100644 --- a/evap/evaluation/migrations/0149_question_counts_for_grade.py +++ b/evap/evaluation/migrations/0149_question_counts_for_grade.py @@ -8,10 +8,7 @@ def set_initial_values(apps, _schema_editor): HEADING = 5 Question = apps.get_model('evaluation', 'Question') - for question in Question.objects.all(): - if question.type in [TEXT, HEADING]: - question.counts_for_grade = False - question.save() + Question.objects.filter(type__in=[TEXT, HEADING]).update(counts_for_grade=False) class Migration(migrations.Migration): diff --git a/evap/evaluation/models.py b/evap/evaluation/models.py index 387304d5a4..0d8010c056 100644 --- a/evap/evaluation/models.py +++ b/evap/evaluation/models.py @@ -1268,7 +1268,7 @@ class Question(models.Model): text_en = models.CharField(max_length=1024, verbose_name=_("question text (english)")) text = translate(en="text_en", de="text_de") allows_additional_textanswers = models.BooleanField(default=True, verbose_name=_("allow additional text answers")) - counts_for_grade = models.BooleanField(default=True, verbose_name=_("counts toward the evaluations grade")) + counts_for_grade = models.BooleanField(default=True, verbose_name=_("counts toward the evaluation's grade")) type = models.PositiveSmallIntegerField(choices=QUESTION_TYPES, verbose_name=_("question type")) From 395fae1a5b33e34406dc216f5459882fea27743f Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 21 Jul 2025 21:33:08 +0200 Subject: [PATCH 11/57] fix bug editing was allowed but not saved when questionnaie in use --- evap/staff/templates/staff_questionnaire_form.html | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/evap/staff/templates/staff_questionnaire_form.html b/evap/staff/templates/staff_questionnaire_form.html index 3433878277..d63cfcbc51 100644 --- a/evap/staff/templates/staff_questionnaire_form.html +++ b/evap/staff/templates/staff_questionnaire_form.html @@ -58,15 +58,13 @@
{% translate 'Questions' %}
+ {% if form_element.allows_additional_textanswers.field.disabled or 'disabled' in form_element.allows_additional_textanswers.field.widget.attrs%} disabled{% endif %} />
+ {% if form_element.counts_for_grade.field.disabled or 'disabled' in form_element.counts_for_grade.field.widget.attrs %} disabled{% endif %} />
From 265c4b4936e56b2a61c39428c41e4a448659ed36 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 21 Jul 2025 21:59:28 +0200 Subject: [PATCH 12/57] fix migations naming and update test_data --- evap/development/fixtures/test_data.json | 2 ++ ..._counts_for_grade.py => 0150_question_counts_for_grade.py} | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) rename evap/evaluation/migrations/{0149_question_counts_for_grade.py => 0150_question_counts_for_grade.py} (90%) diff --git a/evap/development/fixtures/test_data.json b/evap/development/fixtures/test_data.json index 119fe1e2f6..e4cc2386cb 100644 --- a/evap/development/fixtures/test_data.json +++ b/evap/development/fixtures/test_data.json @@ -22884,6 +22884,7 @@ "text_de": "Wieso hast du den Kurs abgewählt?", "text_en": "Why did you drop this course?", "allows_additional_textanswers": false, + "counts_for_grade": false, "type": 0 } }, @@ -22896,6 +22897,7 @@ "text_de": "Wirst du den Kurs nochmal belegen?", "text_en": "Will you take this course again?", "allows_additional_textanswers": true, + "counts_for_grade": false, "type": 3 } }, diff --git a/evap/evaluation/migrations/0149_question_counts_for_grade.py b/evap/evaluation/migrations/0150_question_counts_for_grade.py similarity index 90% rename from evap/evaluation/migrations/0149_question_counts_for_grade.py rename to evap/evaluation/migrations/0150_question_counts_for_grade.py index 8c39292dfe..0f64323d28 100644 --- a/evap/evaluation/migrations/0149_question_counts_for_grade.py +++ b/evap/evaluation/migrations/0150_question_counts_for_grade.py @@ -14,14 +14,14 @@ def set_initial_values(apps, _schema_editor): class Migration(migrations.Migration): dependencies = [ - ("evaluation", "0148_course_cms_id_evaluation_cms_id"), + ("evaluation", "0149_evaluation_dropout_count_alter_questionnaire_type"), ] operations = [ migrations.AddField( model_name="question", name="counts_for_grade", - field=models.BooleanField(default=True, verbose_name="counts toward the evaluations grade"), + field=models.BooleanField(default=True, verbose_name="counts toward the evaluation's grade"), ), migrations.RunPython(set_initial_values, reverse_code=migrations.RunPython.noop), migrations.AddConstraint( From 4322823a92e66fff4fbf60e515a5112d5b689913 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 4 Aug 2025 21:33:02 +0200 Subject: [PATCH 13/57] extend test_data.json with more non counting questions --- evap/development/fixtures/test_data.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evap/development/fixtures/test_data.json b/evap/development/fixtures/test_data.json index e4cc2386cb..097d256125 100644 --- a/evap/development/fixtures/test_data.json +++ b/evap/development/fixtures/test_data.json @@ -21610,7 +21610,7 @@ "text_de": "Das Projekt hat mir Spaß/Freude bereitet.", "text_en": "I enjoyed the project.", "allows_additional_textanswers": true, - "counts_for_grade": true, + "counts_for_grade": false, "type": 1 } }, From 6d3b0541f7d7ffe77cf3df075e32959845348829 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 4 Aug 2025 21:36:48 +0200 Subject: [PATCH 14/57] fixup! add tests to improve coverage --- evap/evaluation/tests/test_models.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/evap/evaluation/tests/test_models.py b/evap/evaluation/tests/test_models.py index 7e33b9e5bd..093fe62751 100644 --- a/evap/evaluation/tests/test_models.py +++ b/evap/evaluation/tests/test_models.py @@ -1166,6 +1166,26 @@ def test_save_for_text_and_heading_question_type(self): allows_additional_textanswers=True, counts_for_grade=True, ) + question_rating = baker.prepare( + Question, + questionnaire=questionaire, + type=QuestionType.NEGATIVE_LIKERT, + allows_additional_textanswers=True, + counts_for_grade=True, + ) + + question_text.save() + question_rating.save() + question_rating.refresh_from_db() + self.assertEqual(question_rating.allows_additional_textanswers, True) + self.assertEqual(question_rating.counts_for_grade, True) + + # Check if setting allows_additional_textanswers and counts_for_grade to False in the save method works + question_rating.type = QuestionType.TEXT + question_rating.save(update_fields=["type"]) + question_rating.refresh_from_db() + self.assertEqual(question_rating.allows_additional_textanswers, False) + self.assertEqual(question_rating.counts_for_grade, False) with patch.object(models.Model, "save") as mock_save: question_text.save(update_fields=["text_de"]) From 5449094e70504ba9277c33fde89f985ec0d7d340 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 4 Aug 2025 21:38:42 +0200 Subject: [PATCH 15/57] fixup! fix bug --- evap/staff/forms.py | 2 ++ evap/staff/templates/staff_questionnaire_form.html | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/evap/staff/forms.py b/evap/staff/forms.py index 46c1226de6..02513e6f41 100644 --- a/evap/staff/forms.py +++ b/evap/staff/forms.py @@ -925,6 +925,8 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self.instance.pk and self.instance.type in [QuestionType.TEXT, QuestionType.HEADING]: + # The disabled attribute on a field would prevent this field from being saved, + # so we use the widget attrs instead self.fields["allows_additional_textanswers"].widget.attrs["disabled"] = "disabled" self.fields["counts_for_grade"].widget.attrs["disabled"] = "disabled" diff --git a/evap/staff/templates/staff_questionnaire_form.html b/evap/staff/templates/staff_questionnaire_form.html index d63cfcbc51..abc77004a2 100644 --- a/evap/staff/templates/staff_questionnaire_form.html +++ b/evap/staff/templates/staff_questionnaire_form.html @@ -58,13 +58,13 @@
{% translate 'Questions' %}
+ {% if form_element.allows_additional_textanswers.field.disabled %} disabled{% endif %} />
+ {% if form_element.counts_for_grade.field.disabled %} disabled{% endif %} />
@@ -121,6 +121,7 @@
{% translate 'Questions' %}
const registerSelectChangedHandlers = function() { document.querySelectorAll(".question-type select").forEach(selectElement => { selectElement.addEventListener('change', selectChangedHandler); + selectElement.dispatchEvent(new Event("change")); }); }; registerSelectChangedHandlers(); From 1f9f10a5cdef7085fbba58ca29b597e300e68802 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 4 Aug 2025 21:39:02 +0200 Subject: [PATCH 16/57] remove unnessary defaults --- evap/student/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evap/student/forms.py b/evap/student/forms.py index 38e8306882..996f49077a 100644 --- a/evap/student/forms.py +++ b/evap/student/forms.py @@ -29,7 +29,7 @@ def from_question(cls, question: Question): class RatingAnswerField(forms.TypedChoiceField): - def __init__(self, widget_choices, *args, allows_textanswer=False, counts_for_grade=True, **kwargs): + def __init__(self, widget_choices, *args, allows_textanswer, counts_for_grade, **kwargs): self.allows_textanswer = allows_textanswer self.counts_for_grade = counts_for_grade kwargs["coerce"] = int From 64cca8982eca93526611d928d53b4ce38fd1166d Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 11 Aug 2025 19:38:22 +0200 Subject: [PATCH 17/57] fix migration naming --- ...on_counts_for_grade.py => 0154_question_counts_for_grade.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename evap/evaluation/migrations/{0150_question_counts_for_grade.py => 0154_question_counts_for_grade.py} (93%) diff --git a/evap/evaluation/migrations/0150_question_counts_for_grade.py b/evap/evaluation/migrations/0154_question_counts_for_grade.py similarity index 93% rename from evap/evaluation/migrations/0150_question_counts_for_grade.py rename to evap/evaluation/migrations/0154_question_counts_for_grade.py index 0f64323d28..2bb549c7a5 100644 --- a/evap/evaluation/migrations/0150_question_counts_for_grade.py +++ b/evap/evaluation/migrations/0154_question_counts_for_grade.py @@ -14,7 +14,7 @@ def set_initial_values(apps, _schema_editor): class Migration(migrations.Migration): dependencies = [ - ("evaluation", "0149_evaluation_dropout_count_alter_questionnaire_type"), + ("evaluation", "0153_alter_userprofile_cc_users_and_more"), ] operations = [ From fc8b6c3184f14f3d5ead7adb7d9710b51002cc7b Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 11 Aug 2025 19:54:29 +0200 Subject: [PATCH 18/57] fix test_data --- evap/development/fixtures/test_data.json | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/evap/development/fixtures/test_data.json b/evap/development/fixtures/test_data.json index 097d256125..1f075dc742 100644 --- a/evap/development/fixtures/test_data.json +++ b/evap/development/fixtures/test_data.json @@ -20730,19 +20730,6 @@ "questionnaires": [] } }, -{ - "model": "evaluation.question", - "pk": 1, - "fields": { - "order": 1, - "questionnaire": 1, - "text_de": "Einzelergebnis", - "text_en": "Single result", - "allows_additional_textanswers": true, - "counts_for_grade": true, - "type": 2 - } -}, { "model": "evaluation.question", "pk": 255, From 95e172db3cba6aa411b86d89219f3bca920eb86f Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 11 Aug 2025 21:58:48 +0200 Subject: [PATCH 19/57] fixup! fix migration naming --- evap/evaluation/migrations/0154_question_counts_for_grade.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evap/evaluation/migrations/0154_question_counts_for_grade.py b/evap/evaluation/migrations/0154_question_counts_for_grade.py index 2bb549c7a5..4d9960c1a1 100644 --- a/evap/evaluation/migrations/0154_question_counts_for_grade.py +++ b/evap/evaluation/migrations/0154_question_counts_for_grade.py @@ -7,7 +7,7 @@ def set_initial_values(apps, _schema_editor): TEXT = 0 HEADING = 5 - Question = apps.get_model('evaluation', 'Question') + Question = apps.get_model("evaluation", "Question") Question.objects.filter(type__in=[TEXT, HEADING]).update(counts_for_grade=False) From b1137b988dd7a5274dbb313d8a71f20e6989f121 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 11 Aug 2025 21:59:09 +0200 Subject: [PATCH 20/57] implement dropped course not counting for grade --- evap/results/tools.py | 5 +- .../templates/staff_questionnaire_form.html | 71 +++++++++++++++++-- 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/evap/results/tools.py b/evap/results/tools.py index c7dcabedc7..23dab084fd 100644 --- a/evap/results/tools.py +++ b/evap/results/tools.py @@ -393,7 +393,9 @@ 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: - if not questionnaire_result.questionnaire.is_dropout: # dropout questionnaires are not counted + # if questionnaire_result.questionnaire.is_dropout: # dropout questionnaires are not counted + # assert not any(result.question.counts_for_grade for result in questionnaire_result.question_results) + if not questionnaire_result.questionnaire.is_dropout: grouped_results[contribution_result.contributor].extend(questionnaire_result.question_results) evaluation_results = grouped_results.pop(None, []) @@ -413,6 +415,7 @@ def calculate_average_distribution(evaluation): ), ] ), + # questions that do not count torward the grade are still counted for the average grade, see #2450 max( (result.count_sum for result in contributor_results if result.question.is_rating_question), default=0, diff --git a/evap/staff/templates/staff_questionnaire_form.html b/evap/staff/templates/staff_questionnaire_form.html index abc77004a2..b60cd4e8bf 100644 --- a/evap/staff/templates/staff_questionnaire_form.html +++ b/evap/staff/templates/staff_questionnaire_form.html @@ -62,7 +62,7 @@
{% translate 'Questions' %}
- @@ -105,16 +105,70 @@
{% translate 'Questions' %}
const selectChangedHandler = function(e) { const checkboxes = this.closest("td.question-type").querySelectorAll("input[type=checkbox]"); + const countsForGradeCheckbox = this.closest("td.question-type").querySelector(".counts-for-grade-checkbox"); + if (e.currentTarget.value == 0 || e.currentTarget.value == 5) { // 0: Text question; 5: Heading checkboxes.forEach(checkbox => { checkbox.checked = false; checkbox.disabled = true; }); } else { - checkboxes.forEach(checkbox => { - checkbox.checked = true; - checkbox.disabled = false; - }); + // Check if this is a dropout questionnaire before enabling checkboxes + const questionnaireTypeSelect = document.querySelector('select[name="type"]'); + if (questionnaireTypeSelect) { + const questionnaireType = parseInt(questionnaireTypeSelect.value); + + if (questionnaireType == 5) { // Dropout questionnaire + checkboxes.forEach(checkbox => { + if (checkbox.classList.contains('counts-for-grade-checkbox')) { + checkbox.checked = false; + checkbox.disabled = true; + } else { + checkbox.checked = true; + checkbox.disabled = false; + } + }); + } else { + checkboxes.forEach(checkbox => { + checkbox.checked = true; + checkbox.disabled = false; + }); + } + } else { + // Fallback: enable all checkboxes if questionnaire type not found + checkboxes.forEach(checkbox => { + checkbox.checked = true; + checkbox.disabled = false; + }); + } + } + }; + + const handleQuestionnaireTypeChange = function() { + const typeSelect = document.querySelector('select[name="type"]'); + if (typeSelect) { + const selectedType = parseInt(typeSelect.value); + const countsForGradeCheckboxes = document.querySelectorAll('.counts-for-grade-checkbox'); + + if (selectedType == 5) { // Dropout questionnaire + countsForGradeCheckboxes.forEach(checkbox => { + checkbox.checked = false; + checkbox.disabled = true; + }); + } else { + countsForGradeCheckboxes.forEach(checkbox => { + const questionTypeSelect = checkbox.closest("td.question-type").querySelector("select"); + if (questionTypeSelect) { + const questionType = parseInt(questionTypeSelect.value); + if (questionType == 0 || questionType == 5 || isNaN(questionType)) { // 0: Text question; 5: Heading; NaN: Not set + checkbox.checked = false; + checkbox.disabled = true; + } else { + checkbox.disabled = false; + } + } + }); + } } }; @@ -123,6 +177,13 @@
{% translate 'Questions' %}
selectElement.addEventListener('change', selectChangedHandler); selectElement.dispatchEvent(new Event("change")); }); + + const questionnaireTypeSelect = document.querySelector('select[name="type"]'); + if (questionnaireTypeSelect) { + questionnaireTypeSelect.addEventListener('change', handleQuestionnaireTypeChange); + // Trigger initial check + handleQuestionnaireTypeChange(); + } }; registerSelectChangedHandlers(); From 39729511c38eab661b0572466a294ebd6f6c4911 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 11 Aug 2025 22:44:00 +0200 Subject: [PATCH 21/57] fix migration naming --- ...on_counts_for_grade.py => 0155_question_counts_for_grade.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename evap/evaluation/migrations/{0154_question_counts_for_grade.py => 0155_question_counts_for_grade.py} (94%) diff --git a/evap/evaluation/migrations/0154_question_counts_for_grade.py b/evap/evaluation/migrations/0155_question_counts_for_grade.py similarity index 94% rename from evap/evaluation/migrations/0154_question_counts_for_grade.py rename to evap/evaluation/migrations/0155_question_counts_for_grade.py index 4d9960c1a1..4c912f8c82 100644 --- a/evap/evaluation/migrations/0154_question_counts_for_grade.py +++ b/evap/evaluation/migrations/0155_question_counts_for_grade.py @@ -14,7 +14,7 @@ def set_initial_values(apps, _schema_editor): class Migration(migrations.Migration): dependencies = [ - ("evaluation", "0153_alter_userprofile_cc_users_and_more"), + ("evaluation", "0154_evaluation_main_language"), ] operations = [ From 6fa978f7595411f64701f1a5186ebd42d9769482 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 18 Aug 2025 18:29:34 +0200 Subject: [PATCH 22/57] fix saving with extra row was not possible before bc of changes to this row --- .../templates/staff_questionnaire_form.html | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/evap/staff/templates/staff_questionnaire_form.html b/evap/staff/templates/staff_questionnaire_form.html index b60cd4e8bf..6b5538a4ee 100644 --- a/evap/staff/templates/staff_questionnaire_form.html +++ b/evap/staff/templates/staff_questionnaire_form.html @@ -105,7 +105,11 @@
{% translate 'Questions' %}
const selectChangedHandler = function(e) { const checkboxes = this.closest("td.question-type").querySelectorAll("input[type=checkbox]"); - const countsForGradeCheckbox = this.closest("td.question-type").querySelector(".counts-for-grade-checkbox"); + + // Do not do anything if the value is not set to enable saving + if(e.currentTarget.value == undefined || e.currentTarget.value == ""){ + return; + } if (e.currentTarget.value == 0 || e.currentTarget.value == 5) { // 0: Text question; 5: Heading checkboxes.forEach(checkbox => { @@ -152,15 +156,28 @@
{% translate 'Questions' %}
if (selectedType == 5) { // Dropout questionnaire countsForGradeCheckboxes.forEach(checkbox => { - checkbox.checked = false; - checkbox.disabled = true; + const questionTypeSelect = checkbox.closest("td.question-type").querySelector("select"); + if (questionTypeSelect) { + // Do not do anything if the value is not set to enable saving + if(questionTypeSelect.value === undefined || questionTypeSelect.value === ""){ + return; + } + + checkbox.checked = false; + checkbox.disabled = true; + } }); } else { countsForGradeCheckboxes.forEach(checkbox => { const questionTypeSelect = checkbox.closest("td.question-type").querySelector("select"); if (questionTypeSelect) { + // Do not do anything if the value is not set to enable saving + if(questionTypeSelect.value === undefined || questionTypeSelect.value === ""){ + return; + } + const questionType = parseInt(questionTypeSelect.value); - if (questionType == 0 || questionType == 5 || isNaN(questionType)) { // 0: Text question; 5: Heading; NaN: Not set + if (questionType == 0 || questionType == 5) { // 0: Text question; 5: Heading checkbox.checked = false; checkbox.disabled = true; } else { @@ -181,8 +198,6 @@
{% translate 'Questions' %}
const questionnaireTypeSelect = document.querySelector('select[name="type"]'); if (questionnaireTypeSelect) { questionnaireTypeSelect.addEventListener('change', handleQuestionnaireTypeChange); - // Trigger initial check - handleQuestionnaireTypeChange(); } }; registerSelectChangedHandlers(); From be83197b50849b9fd17606caa65b329f56aa63c9 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 18 Aug 2025 19:54:01 +0200 Subject: [PATCH 23/57] fixup! implement dropped course not counting for grade --- .../migrations/0155_question_counts_for_grade.py | 7 +++++++ evap/results/tools.py | 4 ++-- evap/staff/forms.py | 6 ++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/evap/evaluation/migrations/0155_question_counts_for_grade.py b/evap/evaluation/migrations/0155_question_counts_for_grade.py index 4c912f8c82..ffa35d232b 100644 --- a/evap/evaluation/migrations/0155_question_counts_for_grade.py +++ b/evap/evaluation/migrations/0155_question_counts_for_grade.py @@ -8,8 +8,15 @@ def set_initial_values(apps, _schema_editor): HEADING = 5 Question = apps.get_model("evaluation", "Question") + Questionnaire = apps.get_model("evaluation", "Questionnaire") + Question.objects.filter(type__in=[TEXT, HEADING]).update(counts_for_grade=False) + dropout_questionnaires = Questionnaire.objects.filter(type=5) # 5: dropout questionnaire + print(dropout_questionnaires) + print(Question.objects.filter(questionnaire__in=dropout_questionnaires)) + Question.objects.filter(questionnaire__in=dropout_questionnaires).update(counts_for_grade=False) + class Migration(migrations.Migration): diff --git a/evap/results/tools.py b/evap/results/tools.py index 23dab084fd..23fdefb892 100644 --- a/evap/results/tools.py +++ b/evap/results/tools.py @@ -393,8 +393,8 @@ 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: - # if questionnaire_result.questionnaire.is_dropout: # dropout questionnaires are not counted - # assert not any(result.question.counts_for_grade for result in questionnaire_result.question_results) + if questionnaire_result.questionnaire.is_dropout: # dropout questionnaires are not counted + assert not any(result.question.counts_for_grade for result in questionnaire_result.question_results) if not questionnaire_result.questionnaire.is_dropout: grouped_results[contribution_result.contributor].extend(questionnaire_result.question_results) diff --git a/evap/staff/forms.py b/evap/staff/forms.py index 02513e6f41..b4f513465b 100644 --- a/evap/staff/forms.py +++ b/evap/staff/forms.py @@ -924,6 +924,9 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + if self.instance.pk and hasattr(self.instance, 'questionnaire') and self.instance.questionnaire and self.instance.questionnaire.is_dropout: + self.fields["counts_for_grade"].widget.attrs["disabled"] = "disabled" + if self.instance.pk and self.instance.type in [QuestionType.TEXT, QuestionType.HEADING]: # The disabled attribute on a field would prevent this field from being saved, # so we use the widget attrs instead @@ -932,6 +935,9 @@ def __init__(self, *args, **kwargs): def clean(self): super().clean() + questionnaire = self.cleaned_data.get("questionnaire") + if questionnaire and questionnaire.is_dropout: + self.cleaned_data["counts_for_grade"] = False if self.cleaned_data.get("type") in [QuestionType.TEXT, QuestionType.HEADING]: self.cleaned_data["allows_additional_textanswers"] = False self.cleaned_data["counts_for_grade"] = False From 5d8925bd2cbf5df67cce579c6ab36c7c2c2b5033 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 18 Aug 2025 20:17:44 +0200 Subject: [PATCH 24/57] fixup! fixup! implement dropped course not counting for grade --- .../migrations/0155_question_counts_for_grade.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/evap/evaluation/migrations/0155_question_counts_for_grade.py b/evap/evaluation/migrations/0155_question_counts_for_grade.py index ffa35d232b..2763166df4 100644 --- a/evap/evaluation/migrations/0155_question_counts_for_grade.py +++ b/evap/evaluation/migrations/0155_question_counts_for_grade.py @@ -1,6 +1,7 @@ # Generated by Django 5.2 on 2025-05-12 21:30 from django.db import migrations, models +from django.db.models import Q def set_initial_values(apps, _schema_editor): @@ -8,14 +9,8 @@ def set_initial_values(apps, _schema_editor): HEADING = 5 Question = apps.get_model("evaluation", "Question") - Questionnaire = apps.get_model("evaluation", "Questionnaire") - Question.objects.filter(type__in=[TEXT, HEADING]).update(counts_for_grade=False) - - dropout_questionnaires = Questionnaire.objects.filter(type=5) # 5: dropout questionnaire - print(dropout_questionnaires) - print(Question.objects.filter(questionnaire__in=dropout_questionnaires)) - Question.objects.filter(questionnaire__in=dropout_questionnaires).update(counts_for_grade=False) + Question.objects.filter(Q(type__in=[TEXT, HEADING]) or Q(questionnaire__type=5)).update(counts_for_grade=False) class Migration(migrations.Migration): From 32b69d068281736dba4e19f79fcfcfa16e4a33b1 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 18 Aug 2025 21:20:22 +0200 Subject: [PATCH 25/57] fixup! fixup! implement dropped course not counting for grade --- evap/results/tools.py | 2 +- evap/staff/forms.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/evap/results/tools.py b/evap/results/tools.py index 23fdefb892..7df405b8b4 100644 --- a/evap/results/tools.py +++ b/evap/results/tools.py @@ -393,7 +393,7 @@ 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: - if questionnaire_result.questionnaire.is_dropout: # dropout questionnaires are not counted + if questionnaire_result.questionnaire.is_dropout: # dropout questionnaires are not counted assert not any(result.question.counts_for_grade for result in questionnaire_result.question_results) if not questionnaire_result.questionnaire.is_dropout: grouped_results[contribution_result.contributor].extend(questionnaire_result.question_results) diff --git a/evap/staff/forms.py b/evap/staff/forms.py index b4f513465b..0a08e0581d 100644 --- a/evap/staff/forms.py +++ b/evap/staff/forms.py @@ -924,7 +924,7 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if self.instance.pk and hasattr(self.instance, 'questionnaire') and self.instance.questionnaire and self.instance.questionnaire.is_dropout: + if self.instance.pk and self.instance.questionnaire and self.instance.questionnaire.is_dropout: self.fields["counts_for_grade"].widget.attrs["disabled"] = "disabled" if self.instance.pk and self.instance.type in [QuestionType.TEXT, QuestionType.HEADING]: From 887dbafcb850b7407e059f3f6b1bcff22a476eff Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 18 Aug 2025 21:21:59 +0200 Subject: [PATCH 26/57] add tests for dropout questionnaires --- evap/results/tests/test_tools.py | 25 +++---------------------- evap/staff/tests/test_forms.py | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/evap/results/tests/test_tools.py b/evap/results/tests/test_tools.py index 4faed79183..3dd24d18cb 100644 --- a/evap/results/tests/test_tools.py +++ b/evap/results/tests/test_tools.py @@ -419,33 +419,14 @@ 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) - def test_grade_calculation_with_non_counting_questions(self): non_counting_question = baker.make( Question, questionnaire=self.questionnaire, type=QuestionType.GRADE, counts_for_grade=False ) counters = [ - *make_rating_answer_counters(self.question_grade, self.contribution1, [1, 0, 0, 0, 0], False), - *make_rating_answer_counters(non_counting_question, self.contribution1, [0, 0, 0, 0, 1], False), + *make_rating_answer_counters(self.question_grade, self.contribution1, [10, 10, 0, 0, 0], False), + *make_rating_answer_counters(non_counting_question, self.contribution1, [0, 0, 0, 0, 10], False), ] RatingAnswerCounter.objects.bulk_create(counters) @@ -453,7 +434,7 @@ def test_grade_calculation_with_non_counting_questions(self): average_grade = distribution_to_grade(calculate_average_distribution(self.evaluation)) - self.assertAlmostEqual(average_grade, 1.0) + self.assertAlmostEqual(average_grade, 1.5) def test_average_questions_distribution(self): grade_question = baker.make( diff --git a/evap/staff/tests/test_forms.py b/evap/staff/tests/test_forms.py index c1a939b79c..32252585b7 100644 --- a/evap/staff/tests/test_forms.py +++ b/evap/staff/tests/test_forms.py @@ -1168,3 +1168,22 @@ def test_fields_disabled_for_text_and_heading(self): form = QuestionForm(instance=question) self.assertFalse(form.fields["allows_additional_textanswers"].widget.attrs.get("disabled")) self.assertFalse(form.fields["counts_for_grade"].widget.attrs.get("disabled")) + + def test_fields_disabled_for_dropout_questionnaire(self): + dropout_questionnaire = baker.make(Questionnaire, type=Questionnaire.Type.DROPOUT) + question = baker.make(Question, type=QuestionType.TEXT, questionnaire=dropout_questionnaire) + + form_data = get_form_data_from_instance(QuestionForm, question) + form_data["counts_for_grade"] = True + + form = QuestionForm(form_data, instance=question) + + self.assertTrue(form.is_valid()) + + self.assertFalse(form.cleaned_data["counts_for_grade"]) + + self.assertTrue(form.fields["counts_for_grade"].widget.attrs.get("disabled")) + + saved_question = form.save() + saved_question.refresh_from_db() + self.assertFalse(saved_question.counts_for_grade) From dd39abb508d97d4289c3e447d9db6181cb86b121 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 20 Oct 2025 19:56:06 +0200 Subject: [PATCH 27/57] make migration and tests better --- .../migrations/0155_question_counts_for_grade.py | 13 ++++++++----- evap/evaluation/tests/test_models.py | 2 +- evap/results/tests/test_tools.py | 8 ++------ 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/evap/evaluation/migrations/0155_question_counts_for_grade.py b/evap/evaluation/migrations/0155_question_counts_for_grade.py index 2763166df4..d6b3660ae8 100644 --- a/evap/evaluation/migrations/0155_question_counts_for_grade.py +++ b/evap/evaluation/migrations/0155_question_counts_for_grade.py @@ -3,14 +3,17 @@ from django.db import migrations, models from django.db.models import Q +TEXT = 0 +HEADING = 5 +DROPOUT_QUESTIONNAIRE = 5 -def set_initial_values(apps, _schema_editor): - TEXT = 0 - HEADING = 5 +def set_initial_values(apps, _schema_editor): Question = apps.get_model("evaluation", "Question") - Question.objects.filter(Q(type__in=[TEXT, HEADING]) or Q(questionnaire__type=5)).update(counts_for_grade=False) + Question.objects.filter(Q(type__in=[TEXT, HEADING]) or Q(questionnaire__type=DROPOUT_QUESTIONNAIRE)).update( + counts_for_grade=False + ) class Migration(migrations.Migration): @@ -30,7 +33,7 @@ class Migration(migrations.Migration): model_name="question", constraint=models.CheckConstraint( condition=models.Q( - models.Q(("type", 0), ("type", 5), _connector="OR", _negated=True), + models.Q(("type", TEXT), ("type", HEADING), _connector="OR", _negated=True), ("counts_for_grade", False), _connector="OR", ), diff --git a/evap/evaluation/tests/test_models.py b/evap/evaluation/tests/test_models.py index 093fe62751..60a6eef200 100644 --- a/evap/evaluation/tests/test_models.py +++ b/evap/evaluation/tests/test_models.py @@ -1152,6 +1152,7 @@ def test_locked_contributor_questionnaire(self): class QuestionTests(TestCase): def test_save_for_text_and_heading_question_type(self): questionaire = baker.make(Questionnaire) + # Use prepare() instead of make() to test Question.save() method behavior question_text = baker.prepare( Question, questionnaire=questionaire, @@ -1174,7 +1175,6 @@ def test_save_for_text_and_heading_question_type(self): counts_for_grade=True, ) - question_text.save() question_rating.save() question_rating.refresh_from_db() self.assertEqual(question_rating.allows_additional_textanswers, True) diff --git a/evap/results/tests/test_tools.py b/evap/results/tests/test_tools.py index 3dd24d18cb..524b34e0dd 100644 --- a/evap/results/tests/test_tools.py +++ b/evap/results/tests/test_tools.py @@ -467,14 +467,10 @@ def test_average_questions_distribution(self): question_results.extend(questionnaire_result.question_results) grade_distribution = average_grade_questions_distribution(question_results) - self.assertIsNotNone(grade_distribution) - self.assertEqual(grade_distribution[0], 1.0) # Only the counting grade question should be included - self.assertEqual(sum(grade_distribution[1:]), 0.0) + self.assertEqual(grade_distribution, (1, 0, 0, 0, 0)) # Only the counting grade question should be included non_grade_distribution = average_non_grade_rating_questions_distribution(question_results) - self.assertIsNotNone(non_grade_distribution) - self.assertEqual(non_grade_distribution[2], 1.0) # Only the counting likert question should be included - self.assertEqual(sum(non_grade_distribution[:2] + non_grade_distribution[3:]), 0.0) + self.assertEqual(non_grade_distribution, (0, 0, 1, 0, 0)) # Only the counting likert question should be included RatingAnswerCounter.objects.all().delete() counters = [ From ba8ee0f75391aedc419ab6dec650e6bb52f853d9 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 20 Oct 2025 22:04:19 +0200 Subject: [PATCH 28/57] fix display bug since we are dispaching the change event on load, all set values (from the db) are overridden in the frontend. This is fine for Heading and Text (since they are unchecked and disabled) but for the other types we cannot set the checkbox to checked. Same applies when its a dropout questtionaire. --- evap/staff/templates/staff_questionnaire_form.html | 2 -- 1 file changed, 2 deletions(-) diff --git a/evap/staff/templates/staff_questionnaire_form.html b/evap/staff/templates/staff_questionnaire_form.html index 6b5538a4ee..d6f695686c 100644 --- a/evap/staff/templates/staff_questionnaire_form.html +++ b/evap/staff/templates/staff_questionnaire_form.html @@ -128,13 +128,11 @@
{% translate 'Questions' %}
checkbox.checked = false; checkbox.disabled = true; } else { - checkbox.checked = true; checkbox.disabled = false; } }); } else { checkboxes.forEach(checkbox => { - checkbox.checked = true; checkbox.disabled = false; }); } From d2e42843b134947d308d1babaf6f1b26fb8f27c3 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 20 Oct 2025 22:05:48 +0200 Subject: [PATCH 29/57] fix migration naming --- ...on_counts_for_grade.py => 0157_question_counts_for_grade.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename evap/evaluation/migrations/{0155_question_counts_for_grade.py => 0157_question_counts_for_grade.py} (95%) diff --git a/evap/evaluation/migrations/0155_question_counts_for_grade.py b/evap/evaluation/migrations/0157_question_counts_for_grade.py similarity index 95% rename from evap/evaluation/migrations/0155_question_counts_for_grade.py rename to evap/evaluation/migrations/0157_question_counts_for_grade.py index d6b3660ae8..28a96559b7 100644 --- a/evap/evaluation/migrations/0155_question_counts_for_grade.py +++ b/evap/evaluation/migrations/0157_question_counts_for_grade.py @@ -19,7 +19,7 @@ def set_initial_values(apps, _schema_editor): class Migration(migrations.Migration): dependencies = [ - ("evaluation", "0154_evaluation_main_language"), + ("evaluation", "0156_alter_userprofile_options"), ] operations = [ From 141fb5c6cff395cdf7d7a14ec953aa3bc0d3ac42 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 20 Oct 2025 23:00:04 +0200 Subject: [PATCH 30/57] format --- evap/results/tests/test_tools.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/evap/results/tests/test_tools.py b/evap/results/tests/test_tools.py index 524b34e0dd..2e11b4034e 100644 --- a/evap/results/tests/test_tools.py +++ b/evap/results/tests/test_tools.py @@ -470,7 +470,9 @@ def test_average_questions_distribution(self): self.assertEqual(grade_distribution, (1, 0, 0, 0, 0)) # Only the counting grade question should be included non_grade_distribution = average_non_grade_rating_questions_distribution(question_results) - self.assertEqual(non_grade_distribution, (0, 0, 1, 0, 0)) # Only the counting likert question should be included + self.assertEqual( + non_grade_distribution, (0, 0, 1, 0, 0) + ) # Only the counting likert question should be included RatingAnswerCounter.objects.all().delete() counters = [ From 9f2e32b0919aeedfe8d4e07999c5827fdee2914b Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 20 Oct 2025 23:01:54 +0200 Subject: [PATCH 31/57] fix display error again use the django widget.attrs this time to implement different behaviour on page load --- evap/staff/forms.py | 6 ++++-- evap/staff/templates/staff_questionnaire_form.html | 9 +++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/evap/staff/forms.py b/evap/staff/forms.py index 0a08e0581d..cb3878aad9 100644 --- a/evap/staff/forms.py +++ b/evap/staff/forms.py @@ -924,8 +924,6 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if self.instance.pk and self.instance.questionnaire and self.instance.questionnaire.is_dropout: - self.fields["counts_for_grade"].widget.attrs["disabled"] = "disabled" if self.instance.pk and self.instance.type in [QuestionType.TEXT, QuestionType.HEADING]: # The disabled attribute on a field would prevent this field from being saved, @@ -933,6 +931,10 @@ def __init__(self, *args, **kwargs): self.fields["allows_additional_textanswers"].widget.attrs["disabled"] = "disabled" self.fields["counts_for_grade"].widget.attrs["disabled"] = "disabled" + # When the questionnaire is a dropout questionnaire, disable the counts_for_grade field on all questions + if self.instance.pk and self.instance.questionnaire and self.instance.questionnaire.is_dropout: + self.fields["counts_for_grade"].widget.attrs["disabled"] = "disabled" + def clean(self): super().clean() questionnaire = self.cleaned_data.get("questionnaire") diff --git a/evap/staff/templates/staff_questionnaire_form.html b/evap/staff/templates/staff_questionnaire_form.html index d6f695686c..7263f37430 100644 --- a/evap/staff/templates/staff_questionnaire_form.html +++ b/evap/staff/templates/staff_questionnaire_form.html @@ -58,13 +58,13 @@
{% translate 'Questions' %}
+ {{form_element.allows_additional_textanswers.field.widget.attrs.disabled}} />
+ type="checkbox" data-keep="true" {% if form_element.counts_for_grade.value %} checked{% endif %} + {{form_element.counts_for_grade.field.widget.attrs.disabled}}/>
@@ -128,11 +128,13 @@
{% translate 'Questions' %}
checkbox.checked = false; checkbox.disabled = true; } else { + checkbox.checked = true; checkbox.disabled = false; } }); } else { checkboxes.forEach(checkbox => { + checkbox.checked = true; checkbox.disabled = false; }); } @@ -190,7 +192,6 @@
{% translate 'Questions' %}
const registerSelectChangedHandlers = function() { document.querySelectorAll(".question-type select").forEach(selectElement => { selectElement.addEventListener('change', selectChangedHandler); - selectElement.dispatchEvent(new Event("change")); }); const questionnaireTypeSelect = document.querySelector('select[name="type"]'); From 5bec14785c5e9d6f84a2ef911cd7ad8e74ca7e3b Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Tue, 21 Oct 2025 23:47:20 +0200 Subject: [PATCH 32/57] move questionnaire and question type select change handlng to seperate typescript file --- .../templates/staff_questionnaire_form.html | 104 +--------------- .../static/ts/src/staff-questionnaire-form.ts | 115 ++++++++++++++++++ 2 files changed, 121 insertions(+), 98 deletions(-) create mode 100644 evap/static/ts/src/staff-questionnaire-form.ts diff --git a/evap/staff/templates/staff_questionnaire_form.html b/evap/staff/templates/staff_questionnaire_form.html index 7263f37430..33252c1aae 100644 --- a/evap/staff/templates/staff_questionnaire_form.html +++ b/evap/staff/templates/staff_questionnaire_form.html @@ -89,6 +89,10 @@
{% translate 'Questions' %}
{% if editable %} {% endif %} {% endblock %} diff --git a/evap/static/ts/src/staff-questionnaire-form.ts b/evap/static/ts/src/staff-questionnaire-form.ts new file mode 100644 index 0000000000..a9df6dba0b --- /dev/null +++ b/evap/static/ts/src/staff-questionnaire-form.ts @@ -0,0 +1,115 @@ +import { selectOrError, assert } from "./utils.js"; + +export class StaffQuestionnaireForm { + private readonly questionTable: HTMLTableElement; + private readonly questionnaireTypeSelect: HTMLSelectElement | null; + + constructor(questionTableId: string) { + this.questionTable = selectOrError(`#${questionTableId}`); + this.questionnaireTypeSelect = document.querySelector('select[name="type"]'); + } + + private selectChangedHandler = (e: Event): void => { + const target = e.currentTarget as HTMLSelectElement; + const checkboxes = target.closest("td.question-type")?.querySelectorAll("input[type=checkbox]"); + + if (!checkboxes) return; + + // Do not do anything if the value is not set to enable saving + // if (target.value === undefined || target.value === "") { + // return; + // } + + if (target.value === "0" || target.value === "5") { // 0: Text question; 5: Heading + checkboxes.forEach(checkbox => { + const checkboxElement = checkbox as HTMLInputElement; + checkboxElement.checked = false; + checkboxElement.disabled = true; + }); + } else { + // Check if this is a dropout questionnaire before enabling checkboxes + if (this.questionnaireTypeSelect) { + const questionnaireType = parseInt(this.questionnaireTypeSelect.value); + + if (questionnaireType === 5) { // Dropout questionnaire + checkboxes.forEach(checkbox => { + const checkboxElement = checkbox as HTMLInputElement; + if (checkboxElement.classList.contains('counts-for-grade-checkbox')) { + checkboxElement.checked = false; + checkboxElement.disabled = true; + } else { + checkboxElement.checked = true; + checkboxElement.disabled = false; + } + }); + } else { + checkboxes.forEach(checkbox => { + const checkboxElement = checkbox as HTMLInputElement; + checkboxElement.checked = true; + checkboxElement.disabled = false; + }); + } + } else { + // Fallback: enable all checkboxes if questionnaire type not found + checkboxes.forEach(checkbox => { + const checkboxElement = checkbox as HTMLInputElement; + checkboxElement.checked = true; + checkboxElement.disabled = false; + }); + } + } + }; + + private handleQuestionnaireTypeChange = (): void => { + if (!this.questionnaireTypeSelect) return; + + const selectedType = parseInt(this.questionnaireTypeSelect.value); + const countsForGradeCheckboxes = document.querySelectorAll('.counts-for-grade-checkbox'); + + if (selectedType === 5) { // Dropout questionnaire + countsForGradeCheckboxes.forEach(checkbox => { + const checkboxElement = checkbox as HTMLInputElement; + const questionTypeSelect = checkboxElement.closest("td.question-type")?.querySelector("select") as HTMLSelectElement; + if (questionTypeSelect) { + // Do not do anything if the value is not set to enable saving + if (questionTypeSelect.value === undefined || questionTypeSelect.value === "") { + return; + } + + checkboxElement.checked = false; + checkboxElement.disabled = true; + } + }); + } else { + countsForGradeCheckboxes.forEach(checkbox => { + const checkboxElement = checkbox as HTMLInputElement; + const questionTypeSelect = checkboxElement.closest("td.question-type").querySelector("select") as HTMLSelectElement; + if (questionTypeSelect) { + // Do not do anything if the value is not set to enable saving + if (questionTypeSelect.value === undefined || questionTypeSelect.value === "") { + return; + } + + const questionType = parseInt(questionTypeSelect.value); + if (questionType === 0 || questionType === 5) { // 0: Text question; 5: Heading + checkboxElement.checked = false; + checkboxElement.disabled = true; + } else { + checkboxElement.disabled = false; + } + } + }); + } + }; + + public registerSelectChangedHandlers = (): void => { + document.querySelectorAll(".question-type select").forEach(selectElement => { + selectElement.addEventListener('change', this.selectChangedHandler); + // selectElement.dispatchEvent(new Event('change')); + }); + + if (this.questionnaireTypeSelect) { + this.questionnaireTypeSelect.addEventListener('change', this.handleQuestionnaireTypeChange); + } + }; +} From 919ad19379d3561219cfa57d435bd4d0e820ee60 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Tue, 21 Oct 2025 23:58:39 +0200 Subject: [PATCH 33/57] use helper functions --- .../static/ts/src/staff-questionnaire-form.ts | 57 ++++++++++--------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/evap/static/ts/src/staff-questionnaire-form.ts b/evap/static/ts/src/staff-questionnaire-form.ts index a9df6dba0b..4091d2a066 100644 --- a/evap/static/ts/src/staff-questionnaire-form.ts +++ b/evap/static/ts/src/staff-questionnaire-form.ts @@ -1,4 +1,4 @@ -import { selectOrError, assert } from "./utils.js"; +import { assertDefined, selectOrError } from "./utils.js"; export class StaffQuestionnaireForm { private readonly questionTable: HTMLTableElement; @@ -11,9 +11,10 @@ export class StaffQuestionnaireForm { private selectChangedHandler = (e: Event): void => { const target = e.currentTarget as HTMLSelectElement; - const checkboxes = target.closest("td.question-type")?.querySelectorAll("input[type=checkbox]"); + const questionTypeCell = target.closest("td.question-type"); + if (!questionTypeCell) return; - if (!checkboxes) return; + const checkboxes = questionTypeCell.querySelectorAll("input[type=checkbox]"); // Do not do anything if the value is not set to enable saving // if (target.value === undefined || target.value === "") { @@ -69,34 +70,38 @@ export class StaffQuestionnaireForm { if (selectedType === 5) { // Dropout questionnaire countsForGradeCheckboxes.forEach(checkbox => { const checkboxElement = checkbox as HTMLInputElement; - const questionTypeSelect = checkboxElement.closest("td.question-type")?.querySelector("select") as HTMLSelectElement; - if (questionTypeSelect) { - // Do not do anything if the value is not set to enable saving - if (questionTypeSelect.value === undefined || questionTypeSelect.value === "") { - return; - } - - checkboxElement.checked = false; - checkboxElement.disabled = true; + const questionTypeCell = checkboxElement.closest("td.question-type"); + assertDefined(questionTypeCell); + + const questionTypeSelect = selectOrError("select", questionTypeCell); + + // Skip empty template forms to prevent Django validation issues + if (questionTypeSelect.value === "") { + return; } + + checkboxElement.checked = false; + checkboxElement.disabled = true; }); } else { countsForGradeCheckboxes.forEach(checkbox => { const checkboxElement = checkbox as HTMLInputElement; - const questionTypeSelect = checkboxElement.closest("td.question-type").querySelector("select") as HTMLSelectElement; - if (questionTypeSelect) { - // Do not do anything if the value is not set to enable saving - if (questionTypeSelect.value === undefined || questionTypeSelect.value === "") { - return; - } - - const questionType = parseInt(questionTypeSelect.value); - if (questionType === 0 || questionType === 5) { // 0: Text question; 5: Heading - checkboxElement.checked = false; - checkboxElement.disabled = true; - } else { - checkboxElement.disabled = false; - } + const questionTypeCell = checkboxElement.closest("td.question-type"); + assertDefined(questionTypeCell); + + const questionTypeSelect = selectOrError("select", questionTypeCell); + + // Skip empty template forms to prevent Django validation issues + if (questionTypeSelect.value === "") { + return; + } + + const questionType = parseInt(questionTypeSelect.value); + if (questionType === 0 || questionType === 5) { // 0: Text question; 5: Heading + checkboxElement.checked = false; + checkboxElement.disabled = true; + } else { + checkboxElement.disabled = false; } }); } From 252af0c5332e4d3677e3c9502380ef9db4103fe9 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Wed, 22 Oct 2025 00:12:03 +0200 Subject: [PATCH 34/57] improve code quality - add constants - use helper functions --- .../static/ts/src/staff-questionnaire-form.ts | 61 +++++++++---------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/evap/static/ts/src/staff-questionnaire-form.ts b/evap/static/ts/src/staff-questionnaire-form.ts index 4091d2a066..7d78db7451 100644 --- a/evap/static/ts/src/staff-questionnaire-form.ts +++ b/evap/static/ts/src/staff-questionnaire-form.ts @@ -1,4 +1,8 @@ -import { assertDefined, selectOrError } from "./utils.js"; +import { assertDefined, saneParseInt, selectOrError } from "./utils.js"; + +const QUESTION_TYPE_TEXT = 0; +const QUESTION_TYPE_HEADING = 5; +const QUESTIONNAIRE_TYPE_DROPOUT = 5; export class StaffQuestionnaireForm { private readonly questionTable: HTMLTableElement; @@ -11,17 +15,13 @@ export class StaffQuestionnaireForm { private selectChangedHandler = (e: Event): void => { const target = e.currentTarget as HTMLSelectElement; + const questionType = saneParseInt(target.value); const questionTypeCell = target.closest("td.question-type"); if (!questionTypeCell) return; - + const checkboxes = questionTypeCell.querySelectorAll("input[type=checkbox]"); - - // Do not do anything if the value is not set to enable saving - // if (target.value === undefined || target.value === "") { - // return; - // } - - if (target.value === "0" || target.value === "5") { // 0: Text question; 5: Heading + + if (questionType === QUESTION_TYPE_TEXT || questionType === QUESTION_TYPE_HEADING) { checkboxes.forEach(checkbox => { const checkboxElement = checkbox as HTMLInputElement; checkboxElement.checked = false; @@ -30,12 +30,12 @@ export class StaffQuestionnaireForm { } else { // Check if this is a dropout questionnaire before enabling checkboxes if (this.questionnaireTypeSelect) { - const questionnaireType = parseInt(this.questionnaireTypeSelect.value); - - if (questionnaireType === 5) { // Dropout questionnaire + const questionnaireType = saneParseInt(this.questionnaireTypeSelect.value); + + if (questionnaireType === QUESTIONNAIRE_TYPE_DROPOUT) { checkboxes.forEach(checkbox => { const checkboxElement = checkbox as HTMLInputElement; - if (checkboxElement.classList.contains('counts-for-grade-checkbox')) { + if (checkboxElement.classList.contains("counts-for-grade-checkbox")) { checkboxElement.checked = false; checkboxElement.disabled = true; } else { @@ -63,23 +63,22 @@ export class StaffQuestionnaireForm { private handleQuestionnaireTypeChange = (): void => { if (!this.questionnaireTypeSelect) return; - - const selectedType = parseInt(this.questionnaireTypeSelect.value); - const countsForGradeCheckboxes = document.querySelectorAll('.counts-for-grade-checkbox'); - - if (selectedType === 5) { // Dropout questionnaire + + const selectedType = saneParseInt(this.questionnaireTypeSelect.value); + const countsForGradeCheckboxes = document.querySelectorAll(".counts-for-grade-checkbox"); + + if (selectedType === QUESTIONNAIRE_TYPE_DROPOUT) { countsForGradeCheckboxes.forEach(checkbox => { const checkboxElement = checkbox as HTMLInputElement; const questionTypeCell = checkboxElement.closest("td.question-type"); assertDefined(questionTypeCell); - + const questionTypeSelect = selectOrError("select", questionTypeCell); - - // Skip empty template forms to prevent Django validation issues + if (questionTypeSelect.value === "") { return; } - + checkboxElement.checked = false; checkboxElement.disabled = true; }); @@ -88,19 +87,19 @@ export class StaffQuestionnaireForm { const checkboxElement = checkbox as HTMLInputElement; const questionTypeCell = checkboxElement.closest("td.question-type"); assertDefined(questionTypeCell); - + const questionTypeSelect = selectOrError("select", questionTypeCell); - - // Skip empty template forms to prevent Django validation issues + if (questionTypeSelect.value === "") { return; } - - const questionType = parseInt(questionTypeSelect.value); - if (questionType === 0 || questionType === 5) { // 0: Text question; 5: Heading + + const questionType = saneParseInt(questionTypeSelect.value); + if (questionType === QUESTION_TYPE_TEXT || questionType === QUESTION_TYPE_HEADING) { checkboxElement.checked = false; checkboxElement.disabled = true; } else { + checkboxElement.checked = true; checkboxElement.disabled = false; } }); @@ -109,12 +108,12 @@ export class StaffQuestionnaireForm { public registerSelectChangedHandlers = (): void => { document.querySelectorAll(".question-type select").forEach(selectElement => { - selectElement.addEventListener('change', this.selectChangedHandler); + selectElement.addEventListener("change", this.selectChangedHandler); // selectElement.dispatchEvent(new Event('change')); }); - + if (this.questionnaireTypeSelect) { - this.questionnaireTypeSelect.addEventListener('change', this.handleQuestionnaireTypeChange); + this.questionnaireTypeSelect.addEventListener("change", this.handleQuestionnaireTypeChange); } }; } From 5d50f744ff8991a33e43f31369252911a026556b Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Wed, 22 Oct 2025 00:30:53 +0200 Subject: [PATCH 35/57] introduce helper functions to disable/enable and check/uncheck checkboxes --- .../static/ts/src/staff-questionnaire-form.ts | 86 +++++++++---------- 1 file changed, 40 insertions(+), 46 deletions(-) diff --git a/evap/static/ts/src/staff-questionnaire-form.ts b/evap/static/ts/src/staff-questionnaire-form.ts index 7d78db7451..881e05468f 100644 --- a/evap/static/ts/src/staff-questionnaire-form.ts +++ b/evap/static/ts/src/staff-questionnaire-form.ts @@ -6,12 +6,33 @@ const QUESTIONNAIRE_TYPE_DROPOUT = 5; export class StaffQuestionnaireForm { private readonly questionTable: HTMLTableElement; - private readonly questionnaireTypeSelect: HTMLSelectElement | null; + private readonly questionnaireTypeSelect: HTMLSelectElement; constructor(questionTableId: string) { this.questionTable = selectOrError(`#${questionTableId}`); - this.questionnaireTypeSelect = document.querySelector('select[name="type"]'); + this.questionnaireTypeSelect = selectOrError('select[name="type"]'); } + private disableAndUncheckCheckbox = (checkbox: HTMLInputElement): void => { + checkbox.checked = false; + checkbox.disabled = true; + }; + + private enableAndCheckCheckbox = (checkbox: HTMLInputElement): void => { + checkbox.checked = true; + checkbox.disabled = false; + }; + + private disableAllCheckboxes = (checkboxes: NodeListOf): void => { + checkboxes.forEach(checkbox => { + this.disableAndUncheckCheckbox(checkbox as HTMLInputElement); + }); + }; + + private enableAllCheckboxes = (checkboxes: NodeListOf): void => { + checkboxes.forEach(checkbox => { + this.enableAndCheckCheckbox(checkbox as HTMLInputElement); + }); + }; private selectChangedHandler = (e: Event): void => { const target = e.currentTarget as HTMLSelectElement; @@ -22,48 +43,27 @@ export class StaffQuestionnaireForm { const checkboxes = questionTypeCell.querySelectorAll("input[type=checkbox]"); if (questionType === QUESTION_TYPE_TEXT || questionType === QUESTION_TYPE_HEADING) { + this.disableAllCheckboxes(checkboxes); + return; + } + // Check if this is a dropout questionnaire before enabling checkboxes + const questionnaireType = saneParseInt(this.questionnaireTypeSelect.value); + + if (questionnaireType === QUESTIONNAIRE_TYPE_DROPOUT) { checkboxes.forEach(checkbox => { const checkboxElement = checkbox as HTMLInputElement; - checkboxElement.checked = false; - checkboxElement.disabled = true; - }); - } else { - // Check if this is a dropout questionnaire before enabling checkboxes - if (this.questionnaireTypeSelect) { - const questionnaireType = saneParseInt(this.questionnaireTypeSelect.value); - - if (questionnaireType === QUESTIONNAIRE_TYPE_DROPOUT) { - checkboxes.forEach(checkbox => { - const checkboxElement = checkbox as HTMLInputElement; - if (checkboxElement.classList.contains("counts-for-grade-checkbox")) { - checkboxElement.checked = false; - checkboxElement.disabled = true; - } else { - checkboxElement.checked = true; - checkboxElement.disabled = false; - } - }); + if (checkboxElement.classList.contains("counts-for-grade-checkbox")) { + this.disableAndUncheckCheckbox(checkboxElement); } else { - checkboxes.forEach(checkbox => { - const checkboxElement = checkbox as HTMLInputElement; - checkboxElement.checked = true; - checkboxElement.disabled = false; - }); + this.enableAndCheckCheckbox(checkboxElement); } - } else { - // Fallback: enable all checkboxes if questionnaire type not found - checkboxes.forEach(checkbox => { - const checkboxElement = checkbox as HTMLInputElement; - checkboxElement.checked = true; - checkboxElement.disabled = false; - }); - } + }); + } else { + this.enableAllCheckboxes(checkboxes); } }; private handleQuestionnaireTypeChange = (): void => { - if (!this.questionnaireTypeSelect) return; - const selectedType = saneParseInt(this.questionnaireTypeSelect.value); const countsForGradeCheckboxes = document.querySelectorAll(".counts-for-grade-checkbox"); @@ -79,8 +79,7 @@ export class StaffQuestionnaireForm { return; } - checkboxElement.checked = false; - checkboxElement.disabled = true; + this.disableAndUncheckCheckbox(checkboxElement); }); } else { countsForGradeCheckboxes.forEach(checkbox => { @@ -96,11 +95,9 @@ export class StaffQuestionnaireForm { const questionType = saneParseInt(questionTypeSelect.value); if (questionType === QUESTION_TYPE_TEXT || questionType === QUESTION_TYPE_HEADING) { - checkboxElement.checked = false; - checkboxElement.disabled = true; + this.disableAndUncheckCheckbox(checkboxElement); } else { - checkboxElement.checked = true; - checkboxElement.disabled = false; + this.enableAndCheckCheckbox(checkboxElement); } }); } @@ -109,11 +106,8 @@ export class StaffQuestionnaireForm { public registerSelectChangedHandlers = (): void => { document.querySelectorAll(".question-type select").forEach(selectElement => { selectElement.addEventListener("change", this.selectChangedHandler); - // selectElement.dispatchEvent(new Event('change')); }); - if (this.questionnaireTypeSelect) { - this.questionnaireTypeSelect.addEventListener("change", this.handleQuestionnaireTypeChange); - } + this.questionnaireTypeSelect.addEventListener("change", this.handleQuestionnaireTypeChange); }; } From b77fa33411c1507e01a2c1925b1c514ffc360093 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 27 Oct 2025 21:53:02 +0100 Subject: [PATCH 36/57] refactor QuestionForm initialization and move checkbox disabling to frontend --- evap/staff/forms.py | 13 ------- .../templates/staff_questionnaire_form.html | 7 ++-- .../static/ts/src/staff-questionnaire-form.ts | 34 +++++++++++++++++++ 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/evap/staff/forms.py b/evap/staff/forms.py index cb3878aad9..4572a2a758 100644 --- a/evap/staff/forms.py +++ b/evap/staff/forms.py @@ -922,19 +922,6 @@ class Meta: "order": forms.HiddenInput(), } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if self.instance.pk and self.instance.type in [QuestionType.TEXT, QuestionType.HEADING]: - # The disabled attribute on a field would prevent this field from being saved, - # so we use the widget attrs instead - self.fields["allows_additional_textanswers"].widget.attrs["disabled"] = "disabled" - self.fields["counts_for_grade"].widget.attrs["disabled"] = "disabled" - - # When the questionnaire is a dropout questionnaire, disable the counts_for_grade field on all questions - if self.instance.pk and self.instance.questionnaire and self.instance.questionnaire.is_dropout: - self.fields["counts_for_grade"].widget.attrs["disabled"] = "disabled" - def clean(self): super().clean() questionnaire = self.cleaned_data.get("questionnaire") diff --git a/evap/staff/templates/staff_questionnaire_form.html b/evap/staff/templates/staff_questionnaire_form.html index 33252c1aae..8de0a3d0b8 100644 --- a/evap/staff/templates/staff_questionnaire_form.html +++ b/evap/staff/templates/staff_questionnaire_form.html @@ -57,14 +57,12 @@
{% translate 'Questions' %}
{% include 'bootstrap_form_field_widget.html' with field=form_element.type %}
+ type="checkbox" data-keep="true" {% if form_element.allows_additional_textanswers.value %} checked{% endif %}/>
+ type="checkbox" data-keep="true" {% if form_element.counts_for_grade.value %} checked{% endif %}/>
@@ -92,6 +90,7 @@
{% translate 'Questions' %}
import { StaffQuestionnaireForm } from "{% static 'js/staff-questionnaire-form.js' %}"; const staffQuestionnaireForm = new StaffQuestionnaireForm("question_table"); + staffQuestionnaireForm.initialize(); const rowChanged = function(row) { const nameDe = row.querySelector('textarea[id$=-text_de]')?.value; diff --git a/evap/static/ts/src/staff-questionnaire-form.ts b/evap/static/ts/src/staff-questionnaire-form.ts index 881e05468f..4dff189169 100644 --- a/evap/static/ts/src/staff-questionnaire-form.ts +++ b/evap/static/ts/src/staff-questionnaire-form.ts @@ -103,6 +103,40 @@ export class StaffQuestionnaireForm { } }; + public initialize = (): void => { + // Initialize the state of all checkboxes based on current question types and questionnaire type + const questionnaireType = saneParseInt(this.questionnaireTypeSelect.value); + + // Iterate through all question rows + document.querySelectorAll(".question-type").forEach(questionTypeCell => { + const questionTypeSelect = selectOrError("select", questionTypeCell); + const questionType = saneParseInt(questionTypeSelect.value); + + // Skip if no question type selected + if (questionTypeSelect.value === "") { + return; + } + + const checkboxes = questionTypeCell.querySelectorAll("input[type=checkbox]"); + + if (questionType === QUESTION_TYPE_TEXT || questionType === QUESTION_TYPE_HEADING) { + checkboxes.forEach(checkbox => { + const checkboxElement = checkbox as HTMLInputElement; + checkboxElement.disabled = true; + }); + } + + if (questionnaireType === QUESTIONNAIRE_TYPE_DROPOUT) { + checkboxes.forEach(checkbox => { + const checkboxElement = checkbox as HTMLInputElement; + if (checkboxElement.classList.contains("counts-for-grade-checkbox")) { + checkboxElement.disabled = true; + } + }); + } + }); + }; + public registerSelectChangedHandlers = (): void => { document.querySelectorAll(".question-type select").forEach(selectElement => { selectElement.addEventListener("change", this.selectChangedHandler); From 0bd6eb420b769cbfe24f794870fca2ca3da3cc17 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 10 Nov 2025 21:29:46 +0100 Subject: [PATCH 37/57] clean up code --- evap/results/tests/test_tools.py | 10 ++++++---- evap/results/tools.py | 3 ++- evap/staff/tests/test_forms.py | 3 --- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/evap/results/tests/test_tools.py b/evap/results/tests/test_tools.py index 2e11b4034e..0e555e2be9 100644 --- a/evap/results/tests/test_tools.py +++ b/evap/results/tests/test_tools.py @@ -461,10 +461,12 @@ def test_average_questions_distribution(self): cache_results(self.evaluation) evaluation_results = get_results(self.evaluation) - question_results = [] - for contribution_result in evaluation_results.contribution_results: - for questionnaire_result in contribution_result.questionnaire_results: - question_results.extend(questionnaire_result.question_results) + question_results = [ + question_result + for contribution_result in evaluation_results.contribution_results + for questionnaire_result in contribution_result.questionnaire_results + for question_result in questionnaire_result.question_results + ] grade_distribution = average_grade_questions_distribution(question_results) self.assertEqual(grade_distribution, (1, 0, 0, 0, 0)) # Only the counting grade question should be included diff --git a/evap/results/tools.py b/evap/results/tools.py index 7df405b8b4..b46e1fe5e8 100644 --- a/evap/results/tools.py +++ b/evap/results/tools.py @@ -415,7 +415,8 @@ def calculate_average_distribution(evaluation): ), ] ), - # questions that do not count torward the grade are still counted for the average grade, see #2450 + # The weight of this contributors grade is supposed to represent the number of students the + # contributor interacted with, which we derive from the max answer count, independently of counts_for_grades. max( (result.count_sum for result in contributor_results if result.question.is_rating_question), default=0, diff --git a/evap/staff/tests/test_forms.py b/evap/staff/tests/test_forms.py index 32252585b7..802320ccd4 100644 --- a/evap/staff/tests/test_forms.py +++ b/evap/staff/tests/test_forms.py @@ -1177,11 +1177,8 @@ def test_fields_disabled_for_dropout_questionnaire(self): form_data["counts_for_grade"] = True form = QuestionForm(form_data, instance=question) - self.assertTrue(form.is_valid()) - self.assertFalse(form.cleaned_data["counts_for_grade"]) - self.assertTrue(form.fields["counts_for_grade"].widget.attrs.get("disabled")) saved_question = form.save() From 8b9e9c7eba5c58a834e26282883c76355f12cc25 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 10 Nov 2025 21:30:09 +0100 Subject: [PATCH 38/57] clean up staff-questionnaire form ts --- .../templates/staff_questionnaire_form.html | 3 +- .../static/ts/src/staff-questionnaire-form.ts | 28 +++++++++---------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/evap/staff/templates/staff_questionnaire_form.html b/evap/staff/templates/staff_questionnaire_form.html index 8de0a3d0b8..85fe9d51b5 100644 --- a/evap/staff/templates/staff_questionnaire_form.html +++ b/evap/staff/templates/staff_questionnaire_form.html @@ -89,8 +89,7 @@
{% translate 'Questions' %}
import { makeFormSortable } from "{% static 'js/sortable-form.js' %}"; import { StaffQuestionnaireForm } from "{% static 'js/staff-questionnaire-form.js' %}"; - const staffQuestionnaireForm = new StaffQuestionnaireForm("question_table"); - staffQuestionnaireForm.initialize(); + const staffQuestionnaireForm = new StaffQuestionnaireForm(document.getElementById("question_table")); const rowChanged = function(row) { const nameDe = row.querySelector('textarea[id$=-text_de]')?.value; diff --git a/evap/static/ts/src/staff-questionnaire-form.ts b/evap/static/ts/src/staff-questionnaire-form.ts index 4dff189169..d39212b9cd 100644 --- a/evap/static/ts/src/staff-questionnaire-form.ts +++ b/evap/static/ts/src/staff-questionnaire-form.ts @@ -8,33 +8,34 @@ export class StaffQuestionnaireForm { private readonly questionTable: HTMLTableElement; private readonly questionnaireTypeSelect: HTMLSelectElement; - constructor(questionTableId: string) { - this.questionTable = selectOrError(`#${questionTableId}`); - this.questionnaireTypeSelect = selectOrError('select[name="type"]'); + constructor(questionTable: HTMLTableElement) { + this.questionTable = questionTable; + this.questionnaireTypeSelect = selectOrError("#id_type"); + this.initialize(); } - private disableAndUncheckCheckbox = (checkbox: HTMLInputElement): void => { + private disableAndUncheckCheckbox = (checkbox: HTMLInputElement) => { checkbox.checked = false; checkbox.disabled = true; }; - private enableAndCheckCheckbox = (checkbox: HTMLInputElement): void => { + private enableAndCheckCheckbox = (checkbox: HTMLInputElement) => { checkbox.checked = true; checkbox.disabled = false; }; - private disableAllCheckboxes = (checkboxes: NodeListOf): void => { + private disableAllCheckboxes = (checkboxes: NodeListOf) => { checkboxes.forEach(checkbox => { this.disableAndUncheckCheckbox(checkbox as HTMLInputElement); }); }; - private enableAllCheckboxes = (checkboxes: NodeListOf): void => { + private enableAllCheckboxes = (checkboxes: NodeListOf) => { checkboxes.forEach(checkbox => { this.enableAndCheckCheckbox(checkbox as HTMLInputElement); }); }; - private selectChangedHandler = (e: Event): void => { + private handleQuestionTypeChange = (e: Event) => { const target = e.currentTarget as HTMLSelectElement; const questionType = saneParseInt(target.value); const questionTypeCell = target.closest("td.question-type"); @@ -46,9 +47,8 @@ export class StaffQuestionnaireForm { this.disableAllCheckboxes(checkboxes); return; } - // Check if this is a dropout questionnaire before enabling checkboxes - const questionnaireType = saneParseInt(this.questionnaireTypeSelect.value); + const questionnaireType = saneParseInt(this.questionnaireTypeSelect.value); if (questionnaireType === QUESTIONNAIRE_TYPE_DROPOUT) { checkboxes.forEach(checkbox => { const checkboxElement = checkbox as HTMLInputElement; @@ -63,7 +63,7 @@ export class StaffQuestionnaireForm { } }; - private handleQuestionnaireTypeChange = (): void => { + private handleQuestionnaireTypeChange = () => { const selectedType = saneParseInt(this.questionnaireTypeSelect.value); const countsForGradeCheckboxes = document.querySelectorAll(".counts-for-grade-checkbox"); @@ -103,7 +103,7 @@ export class StaffQuestionnaireForm { } }; - public initialize = (): void => { + private initialize = () => { // Initialize the state of all checkboxes based on current question types and questionnaire type const questionnaireType = saneParseInt(this.questionnaireTypeSelect.value); @@ -137,9 +137,9 @@ export class StaffQuestionnaireForm { }); }; - public registerSelectChangedHandlers = (): void => { + public registerSelectChangedHandlers = () => { document.querySelectorAll(".question-type select").forEach(selectElement => { - selectElement.addEventListener("change", this.selectChangedHandler); + selectElement.addEventListener("change", this.handleQuestionTypeChange); }); this.questionnaireTypeSelect.addEventListener("change", this.handleQuestionnaireTypeChange); From 0d68697f70b3dd675dd061abc2cb10de0123f691 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 10 Nov 2025 21:47:03 +0100 Subject: [PATCH 39/57] fix migration to use exact same contraint logic use bitwise "|" instead of django "or" --- evap/evaluation/migrations/0157_question_counts_for_grade.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evap/evaluation/migrations/0157_question_counts_for_grade.py b/evap/evaluation/migrations/0157_question_counts_for_grade.py index 28a96559b7..1e55a75cf9 100644 --- a/evap/evaluation/migrations/0157_question_counts_for_grade.py +++ b/evap/evaluation/migrations/0157_question_counts_for_grade.py @@ -11,7 +11,7 @@ def set_initial_values(apps, _schema_editor): Question = apps.get_model("evaluation", "Question") - Question.objects.filter(Q(type__in=[TEXT, HEADING]) or Q(questionnaire__type=DROPOUT_QUESTIONNAIRE)).update( + Question.objects.filter(Q(type__in=[TEXT, HEADING]) | Q(questionnaire__type=DROPOUT_QUESTIONNAIRE)).update( counts_for_grade=False ) From babf32898f2af0af09526f58edc65184e8beb7a4 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 24 Nov 2025 18:42:50 +0100 Subject: [PATCH 40/57] deduplicate code and make more readable --- .../static/ts/src/staff-questionnaire-form.ts | 60 +++++++------------ 1 file changed, 20 insertions(+), 40 deletions(-) diff --git a/evap/static/ts/src/staff-questionnaire-form.ts b/evap/static/ts/src/staff-questionnaire-form.ts index d39212b9cd..2fbcefcf81 100644 --- a/evap/static/ts/src/staff-questionnaire-form.ts +++ b/evap/static/ts/src/staff-questionnaire-form.ts @@ -67,73 +67,53 @@ export class StaffQuestionnaireForm { const selectedType = saneParseInt(this.questionnaireTypeSelect.value); const countsForGradeCheckboxes = document.querySelectorAll(".counts-for-grade-checkbox"); - if (selectedType === QUESTIONNAIRE_TYPE_DROPOUT) { - countsForGradeCheckboxes.forEach(checkbox => { - const checkboxElement = checkbox as HTMLInputElement; - const questionTypeCell = checkboxElement.closest("td.question-type"); - assertDefined(questionTypeCell); + countsForGradeCheckboxes.forEach(checkbox => { + const checkboxElement = checkbox as HTMLInputElement; + const questionTypeCell = checkboxElement.closest("td.question-type"); + assertDefined(questionTypeCell); - const questionTypeSelect = selectOrError("select", questionTypeCell); + const questionTypeSelect = selectOrError("select", questionTypeCell); - if (questionTypeSelect.value === "") { - return; - } + if (questionTypeSelect.value === "") { + return; + } + if (selectedType === QUESTIONNAIRE_TYPE_DROPOUT) { this.disableAndUncheckCheckbox(checkboxElement); - }); - } else { - countsForGradeCheckboxes.forEach(checkbox => { - const checkboxElement = checkbox as HTMLInputElement; - const questionTypeCell = checkboxElement.closest("td.question-type"); - assertDefined(questionTypeCell); - - const questionTypeSelect = selectOrError("select", questionTypeCell); - - if (questionTypeSelect.value === "") { - return; - } - + } else { const questionType = saneParseInt(questionTypeSelect.value); if (questionType === QUESTION_TYPE_TEXT || questionType === QUESTION_TYPE_HEADING) { this.disableAndUncheckCheckbox(checkboxElement); } else { this.enableAndCheckCheckbox(checkboxElement); } - }); - } + } + }); }; private initialize = () => { // Initialize the state of all checkboxes based on current question types and questionnaire type const questionnaireType = saneParseInt(this.questionnaireTypeSelect.value); - // Iterate through all question rows document.querySelectorAll(".question-type").forEach(questionTypeCell => { const questionTypeSelect = selectOrError("select", questionTypeCell); const questionType = saneParseInt(questionTypeSelect.value); - // Skip if no question type selected if (questionTypeSelect.value === "") { return; } const checkboxes = questionTypeCell.querySelectorAll("input[type=checkbox]"); + const isTextOrHeading = questionType === QUESTION_TYPE_TEXT || questionType === QUESTION_TYPE_HEADING; - if (questionType === QUESTION_TYPE_TEXT || questionType === QUESTION_TYPE_HEADING) { - checkboxes.forEach(checkbox => { - const checkboxElement = checkbox as HTMLInputElement; - checkboxElement.disabled = true; - }); - } + checkboxes.forEach(checkbox => { + const checkboxElement = checkbox as HTMLInputElement; + const isCountsForGrade = checkboxElement.classList.contains("counts-for-grade-checkbox"); - if (questionnaireType === QUESTIONNAIRE_TYPE_DROPOUT) { - checkboxes.forEach(checkbox => { - const checkboxElement = checkbox as HTMLInputElement; - if (checkboxElement.classList.contains("counts-for-grade-checkbox")) { - checkboxElement.disabled = true; - } - }); - } + if (isTextOrHeading || (questionnaireType === QUESTIONNAIRE_TYPE_DROPOUT && isCountsForGrade)) { + checkboxElement.disabled = true; + } + }); }); }; From a9f2402bbb197f196275160dfdfc7ee6d35c27f8 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 24 Nov 2025 19:44:03 +0100 Subject: [PATCH 41/57] refactor event listener to use delegation on table --- .../templates/staff_questionnaire_form.html | 3 +- .../static/ts/src/staff-questionnaire-form.ts | 48 +++++++++++-------- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/evap/staff/templates/staff_questionnaire_form.html b/evap/staff/templates/staff_questionnaire_form.html index 85fe9d51b5..349e927e59 100644 --- a/evap/staff/templates/staff_questionnaire_form.html +++ b/evap/staff/templates/staff_questionnaire_form.html @@ -101,11 +101,10 @@
{% translate 'Questions' %}
el.remove(); }); applyTomSelect(row.querySelectorAll("select")); - staffQuestionnaireForm.registerSelectChangedHandlers(); }; makeFormSortable("question_table", "questions", rowChanged, rowAdded, true, true); - staffQuestionnaireForm.registerSelectChangedHandlers(); + {% endif %} {% endblock %} diff --git a/evap/static/ts/src/staff-questionnaire-form.ts b/evap/static/ts/src/staff-questionnaire-form.ts index 2fbcefcf81..7dd8705233 100644 --- a/evap/static/ts/src/staff-questionnaire-form.ts +++ b/evap/static/ts/src/staff-questionnaire-form.ts @@ -11,6 +11,10 @@ export class StaffQuestionnaireForm { constructor(questionTable: HTMLTableElement) { this.questionTable = questionTable; this.questionnaireTypeSelect = selectOrError("#id_type"); + + this.questionTable.addEventListener("change", this.handleQuestionTypeChange); + this.questionnaireTypeSelect.addEventListener("change", this.handleQuestionnaireTypeChange); + this.initialize(); } private disableAndUncheckCheckbox = (checkbox: HTMLInputElement) => { @@ -36,30 +40,32 @@ export class StaffQuestionnaireForm { }; private handleQuestionTypeChange = (e: Event) => { - const target = e.currentTarget as HTMLSelectElement; - const questionType = saneParseInt(target.value); + const target = e.target as HTMLElement; const questionTypeCell = target.closest("td.question-type"); - if (!questionTypeCell) return; - - const checkboxes = questionTypeCell.querySelectorAll("input[type=checkbox]"); + if (questionTypeCell && target.matches("select")) { + const questionTypeSelect = target as HTMLSelectElement; + const questionType = saneParseInt(questionTypeSelect.value); + + const checkboxes = questionTypeCell.querySelectorAll("input[type=checkbox]"); - if (questionType === QUESTION_TYPE_TEXT || questionType === QUESTION_TYPE_HEADING) { - this.disableAllCheckboxes(checkboxes); - return; - } + if (questionType === QUESTION_TYPE_TEXT || questionType === QUESTION_TYPE_HEADING) { + this.disableAllCheckboxes(checkboxes); + return; + } - const questionnaireType = saneParseInt(this.questionnaireTypeSelect.value); - if (questionnaireType === QUESTIONNAIRE_TYPE_DROPOUT) { - checkboxes.forEach(checkbox => { - const checkboxElement = checkbox as HTMLInputElement; - if (checkboxElement.classList.contains("counts-for-grade-checkbox")) { - this.disableAndUncheckCheckbox(checkboxElement); - } else { - this.enableAndCheckCheckbox(checkboxElement); - } - }); - } else { - this.enableAllCheckboxes(checkboxes); + const questionnaireType = saneParseInt(this.questionnaireTypeSelect.value); + if (questionnaireType === QUESTIONNAIRE_TYPE_DROPOUT) { + checkboxes.forEach(checkbox => { + const checkboxElement = checkbox as HTMLInputElement; + if (checkboxElement.classList.contains("counts-for-grade-checkbox")) { + this.disableAndUncheckCheckbox(checkboxElement); + } else { + this.enableAndCheckCheckbox(checkboxElement); + } + }); + } else { + this.enableAllCheckboxes(checkboxes); + } } }; From 75ae977787c0a3bb39e5486ed28140b0406c5650 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 24 Nov 2025 21:18:30 +0100 Subject: [PATCH 42/57] add live tests for staff questionnaire edit form --- evap/staff/tests/test_live.py | 100 ++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/evap/staff/tests/test_live.py b/evap/staff/tests/test_live.py index 35ac73d67f..f41d159c44 100644 --- a/evap/staff/tests/test_live.py +++ b/evap/staff/tests/test_live.py @@ -18,6 +18,7 @@ Program, Question, Questionnaire, + QuestionType, Semester, TextAnswer, UserProfile, @@ -178,6 +179,105 @@ def test_collapse_without_editor_approved(self) -> None: self.assertEqual(counter.text, "0") +class QuestionnaireFormLiveTest(LiveServerTest): + def test_question_type_disabling_logic(self): + questionnaire = baker.make(Questionnaire, type=Questionnaire.Type.TOP) + baker.make(Question, questionnaire=questionnaire, type=QuestionType.POSITIVE_LIKERT) + + with self.enter_staff_mode(): + self.selenium.get(self.reverse("staff:questionnaire_edit", args=[questionnaire.pk])) + + # Part 1: Edit Existing Question + row = self.wait.until(visibility_of_element_located((By.CSS_SELECTOR, "#question_table tbody tr"))) + type_select = row.find_element(By.CSS_SELECTOR, "select[id$='-type']") + + self.select_tom_select_option(type_select, str(QuestionType.TEXT)) + self.assertTrue( + row.find_element(By.CSS_SELECTOR, "input[id$='-allows_additional_textanswers']").get_attribute("disabled") + ) + self.assertTrue(row.find_element(By.CSS_SELECTOR, "input[id$='-counts_for_grade']").get_attribute("disabled")) + + self.select_tom_select_option(type_select, str(QuestionType.POSITIVE_LIKERT)) + self.assertFalse( + row.find_element(By.CSS_SELECTOR, "input[id$='-allows_additional_textanswers']").get_attribute("disabled") + ) + self.assertFalse(row.find_element(By.CSS_SELECTOR, "input[id$='-counts_for_grade']").get_attribute("disabled")) + + self.select_tom_select_option(type_select, str(QuestionType.HEADING)) + self.assertTrue( + row.find_element(By.CSS_SELECTOR, "input[id$='-allows_additional_textanswers']").get_attribute("disabled") + ) + self.assertTrue(row.find_element(By.CSS_SELECTOR, "input[id$='-counts_for_grade']").get_attribute("disabled")) + + # Part 2: Add New Question + self.selenium.find_element(By.CLASS_NAME, "add-row").click() + + # Wait until there are at least 3 rows (2 existing (since there is the default new row) + 1 new) + self.wait.until(lambda driver: len(driver.find_elements(By.CSS_SELECTOR, "#question_table tbody tr")) >= 3) + + new_row = self.selenium.find_elements(By.CSS_SELECTOR, "#question_table tbody tr")[ + -2 + ] # the last row is the add row button + new_type_select = new_row.find_element(By.CSS_SELECTOR, "select[id$='-type']") + + self.select_tom_select_option(new_type_select, str(QuestionType.TEXT)) + self.assertTrue( + new_row.find_element(By.CSS_SELECTOR, "input[id$='-allows_additional_textanswers']").get_attribute( + "disabled" + ) + ) + self.assertTrue( + new_row.find_element(By.CSS_SELECTOR, "input[id$='-counts_for_grade']").get_attribute("disabled") + ) + + self.select_tom_select_option(new_type_select, str(QuestionType.POSITIVE_LIKERT)) + self.assertFalse( + new_row.find_element(By.CSS_SELECTOR, "input[id$='-allows_additional_textanswers']").get_attribute( + "disabled" + ) + ) + self.assertFalse( + new_row.find_element(By.CSS_SELECTOR, "input[id$='-counts_for_grade']").get_attribute("disabled") + ) + + self.select_tom_select_option(new_type_select, str(QuestionType.HEADING)) + self.assertTrue( + new_row.find_element(By.CSS_SELECTOR, "input[id$='-allows_additional_textanswers']").get_attribute( + "disabled" + ) + ) + self.assertTrue( + new_row.find_element(By.CSS_SELECTOR, "input[id$='-counts_for_grade']").get_attribute("disabled") + ) + + def test_questionnaire_type_disabling_logic(self): + questionnaire = baker.make(Questionnaire, type=Questionnaire.Type.TOP) + baker.make(Question, questionnaire=questionnaire, type=QuestionType.POSITIVE_LIKERT) + + with self.enter_staff_mode(): + self.selenium.get(self.reverse("staff:questionnaire_edit", args=[questionnaire.pk])) + + row = self.wait.until(visibility_of_element_located((By.CSS_SELECTOR, "#question_table tbody tr"))) + questionnaire_type_select = self.selenium.find_element(By.ID, "id_type") + + # Change to Dropout + self.select_tom_select_option(questionnaire_type_select, str(Questionnaire.Type.DROPOUT)) + self.assertTrue(row.find_element(By.CSS_SELECTOR, "input[id$='-counts_for_grade']").get_attribute("disabled")) + self.assertFalse( + row.find_element(By.CSS_SELECTOR, "input[id$='-allows_additional_textanswers']").get_attribute("disabled") + ) + + # Change back to Top + self.select_tom_select_option(questionnaire_type_select, str(Questionnaire.Type.TOP)) + self.assertFalse(row.find_element(By.CSS_SELECTOR, "input[id$='-counts_for_grade']").get_attribute("disabled")) + self.assertFalse( + row.find_element(By.CSS_SELECTOR, "input[id$='-allows_additional_textanswers']").get_attribute("disabled") + ) + + def select_tom_select_option(self, select_element, value): + self.selenium.execute_script(f"arguments[0].tomselect.setValue('{value}');", select_element) + + class TextAnswerEditLiveTest(LiveServerTest): def test_edit_textanswer_redirect(self): """Regression test for #1696""" From 273402804f795e4bd2b621758724cc1ff3178622 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 24 Nov 2025 21:19:06 +0100 Subject: [PATCH 43/57] remove old non working tests --- evap/staff/tests/test_forms.py | 32 +------------------------------- 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/evap/staff/tests/test_forms.py b/evap/staff/tests/test_forms.py index 802320ccd4..095d86c31a 100644 --- a/evap/staff/tests/test_forms.py +++ b/evap/staff/tests/test_forms.py @@ -1153,34 +1153,4 @@ def test_save_makes_a_copy(self): class QuestionFormTests(TestCase): - def test_fields_disabled_for_text_and_heading(self): - question = baker.make(Question, type=QuestionType.TEXT) - form = QuestionForm(instance=question) - self.assertTrue(form.fields["allows_additional_textanswers"].widget.attrs.get("disabled")) - self.assertTrue(form.fields["counts_for_grade"].widget.attrs.get("disabled")) - - question = baker.make(Question, type=QuestionType.HEADING) - form = QuestionForm(instance=question) - self.assertTrue(form.fields["allows_additional_textanswers"].widget.attrs.get("disabled")) - self.assertTrue(form.fields["counts_for_grade"].widget.attrs.get("disabled")) - - question = baker.make(Question, type=QuestionType.POSITIVE_LIKERT) - form = QuestionForm(instance=question) - self.assertFalse(form.fields["allows_additional_textanswers"].widget.attrs.get("disabled")) - self.assertFalse(form.fields["counts_for_grade"].widget.attrs.get("disabled")) - - def test_fields_disabled_for_dropout_questionnaire(self): - dropout_questionnaire = baker.make(Questionnaire, type=Questionnaire.Type.DROPOUT) - question = baker.make(Question, type=QuestionType.TEXT, questionnaire=dropout_questionnaire) - - form_data = get_form_data_from_instance(QuestionForm, question) - form_data["counts_for_grade"] = True - - form = QuestionForm(form_data, instance=question) - self.assertTrue(form.is_valid()) - self.assertFalse(form.cleaned_data["counts_for_grade"]) - self.assertTrue(form.fields["counts_for_grade"].widget.attrs.get("disabled")) - - saved_question = form.save() - saved_question.refresh_from_db() - self.assertFalse(saved_question.counts_for_grade) + pass From 72183caf693bae0cdaeac1f50f2ddf524166b6aa Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 24 Nov 2025 21:35:38 +0100 Subject: [PATCH 44/57] add regression test for #2539 --- evap/staff/tests/test_forms.py | 37 +++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/evap/staff/tests/test_forms.py b/evap/staff/tests/test_forms.py index 095d86c31a..92bd9b0a92 100644 --- a/evap/staff/tests/test_forms.py +++ b/evap/staff/tests/test_forms.py @@ -1153,4 +1153,39 @@ def test_save_makes_a_copy(self): class QuestionFormTests(TestCase): - pass + def test_allows_additional_textanswers_persistence_after_type_change(self): + """ + Test that allows_additional_textanswers can be changed and persisted correctly + even after changing the question type from TEXT/HEADING to a Likert type. + Regression test for #2539. + """ + questionnaire = baker.make(Questionnaire, type=Questionnaire.Type.TOP) + question = baker.make( + Question, + questionnaire=questionnaire, + type=QuestionType.TEXT, + allows_additional_textanswers=False, + ) + + # First save: set allows_additional_textanswers to False + form_data = get_form_data_from_instance(QuestionForm, question) + self.assertFalse(form_data["allows_additional_textanswers"]) + + form = QuestionForm(form_data, instance=question) + self.assertTrue(form.is_valid()) + form.save() + + question.refresh_from_db() + self.assertFalse(question.allows_additional_textanswers) + + form_data = get_form_data_from_instance(QuestionForm, question) + form_data["type"] = QuestionType.POSITIVE_LIKERT + form_data["allows_additional_textanswers"] = True + + form = QuestionForm(form_data, instance=question) + self.assertTrue(form.is_valid()) + form.save() + + question.refresh_from_db() + self.assertEqual(question.type, QuestionType.POSITIVE_LIKERT) + self.assertTrue(question.allows_additional_textanswers) From 00045f71ac6f187051706402336958d2910db7c7 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 24 Nov 2025 21:35:46 +0100 Subject: [PATCH 45/57] format --- evap/static/ts/src/staff-questionnaire-form.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evap/static/ts/src/staff-questionnaire-form.ts b/evap/static/ts/src/staff-questionnaire-form.ts index 7dd8705233..004c2d253b 100644 --- a/evap/static/ts/src/staff-questionnaire-form.ts +++ b/evap/static/ts/src/staff-questionnaire-form.ts @@ -45,7 +45,7 @@ export class StaffQuestionnaireForm { if (questionTypeCell && target.matches("select")) { const questionTypeSelect = target as HTMLSelectElement; const questionType = saneParseInt(questionTypeSelect.value); - + const checkboxes = questionTypeCell.querySelectorAll("input[type=checkbox]"); if (questionType === QUESTION_TYPE_TEXT || questionType === QUESTION_TYPE_HEADING) { From 4a0b8b3584ba2a2ff78db85eb0200826d480d18b Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 24 Nov 2025 21:49:10 +0100 Subject: [PATCH 46/57] fix migration naming --- ...on_counts_for_grade.py => 0158_question_counts_for_grade.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename evap/evaluation/migrations/{0157_question_counts_for_grade.py => 0158_question_counts_for_grade.py} (95%) diff --git a/evap/evaluation/migrations/0157_question_counts_for_grade.py b/evap/evaluation/migrations/0158_question_counts_for_grade.py similarity index 95% rename from evap/evaluation/migrations/0157_question_counts_for_grade.py rename to evap/evaluation/migrations/0158_question_counts_for_grade.py index 1e55a75cf9..96b0953d5b 100644 --- a/evap/evaluation/migrations/0157_question_counts_for_grade.py +++ b/evap/evaluation/migrations/0158_question_counts_for_grade.py @@ -19,7 +19,7 @@ def set_initial_values(apps, _schema_editor): class Migration(migrations.Migration): dependencies = [ - ("evaluation", "0156_alter_userprofile_options"), + ("evaluation", "0157_coursetype_skip_on_automated_import"), ] operations = [ From d55e3a29e7aa49bde5897cc2e9a5f55c7f8f5b43 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 24 Nov 2025 23:30:41 +0100 Subject: [PATCH 47/57] add tests for handling dropout questionnaires in distribution calculations and form validation --- evap/results/tests/test_tools.py | 41 ++++++++++++++++++++++++++++++++ evap/staff/tests/test_forms.py | 12 ++++++++++ 2 files changed, 53 insertions(+) diff --git a/evap/results/tests/test_tools.py b/evap/results/tests/test_tools.py index 0e555e2be9..e6f4bfe9cf 100644 --- a/evap/results/tests/test_tools.py +++ b/evap/results/tests/test_tools.py @@ -494,6 +494,47 @@ def test_average_questions_distribution(self): self.assertIsNone(average_grade_questions_distribution(question_results)) self.assertIsNone(average_non_grade_rating_questions_distribution(question_results)) + def test_dropout_questionnaire_excluded_from_distribution(self): + make_rating_answer_counters(self.question_grade, self.general_contribution, [0, 0, 0, 0, 10]) + cache_results(self.evaluation) + + distribution_without_dropout = calculate_average_distribution(self.evaluation) + self.assertIsNotNone(distribution_without_dropout) + + dropout_questionnaire = baker.make(Questionnaire, type=Questionnaire.Type.DROPOUT) + dropout_question = baker.make( + Question, + questionnaire=dropout_questionnaire, + type=QuestionType.GRADE, + counts_for_grade=False, + ) + self.evaluation.general_contribution.questionnaires.add(dropout_questionnaire) + make_rating_answer_counters(dropout_question, self.evaluation.general_contribution, [10, 0, 0, 0, 0]) + cache_results(self.evaluation) + + # Should not raise an AssertionError + distribution_with_dropout = calculate_average_distribution(self.evaluation) + + self.assertEqual(distribution_without_dropout, distribution_with_dropout) + + def test_dropout_questionnaire_with_counts_for_grade_true(self): + dropout_questionnaire = baker.make(Questionnaire, type=Questionnaire.Type.DROPOUT) + # Create a question with counts_for_grade=True (bypassing form validation) + dropout_question = baker.make( + Question, + questionnaire=dropout_questionnaire, + type=QuestionType.GRADE, + counts_for_grade=True, + ) + self.evaluation.general_contribution.questionnaires.add(dropout_questionnaire) + + make_rating_answer_counters(dropout_question, self.evaluation.general_contribution, [1, 0, 0, 0, 0]) + cache_results(self.evaluation) + + # Should raise AssertionError because dropout questionnaire has counts_for_grade=True + with self.assertRaises(AssertionError): + calculate_average_distribution(self.evaluation) + class TestTextAnswerVisibilityInfo(TestCase): @classmethod diff --git a/evap/staff/tests/test_forms.py b/evap/staff/tests/test_forms.py index 92bd9b0a92..884b7b362a 100644 --- a/evap/staff/tests/test_forms.py +++ b/evap/staff/tests/test_forms.py @@ -1189,3 +1189,15 @@ def test_allows_additional_textanswers_persistence_after_type_change(self): question.refresh_from_db() self.assertEqual(question.type, QuestionType.POSITIVE_LIKERT) self.assertTrue(question.allows_additional_textanswers) + + def test_clean_for_dropout_questionnaire(self): + dropout_questionnaire = baker.make(Questionnaire, type=Questionnaire.Type.DROPOUT) + question = baker.make(Question, questionnaire=dropout_questionnaire, type=QuestionType.POSITIVE_LIKERT) + + form_data = get_form_data_from_instance(QuestionForm, question) + form_data["counts_for_grade"] = True + + form = QuestionForm(form_data, instance=question) + # The clean method should override counts_for_grade to False + self.assertTrue(form.is_valid()) + self.assertFalse(form.cleaned_data["counts_for_grade"]) From a64383db14c7254c0d3fc4242975bd2b31885dc5 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 15 Dec 2025 23:24:08 +0100 Subject: [PATCH 48/57] improve code quality and readability --- evap/results/tests/test_tools.py | 26 +++++--------- .../static/ts/src/staff-questionnaire-form.ts | 34 +++++++------------ 2 files changed, 21 insertions(+), 39 deletions(-) diff --git a/evap/results/tests/test_tools.py b/evap/results/tests/test_tools.py index e6f4bfe9cf..4fce2cfe3b 100644 --- a/evap/results/tests/test_tools.py +++ b/evap/results/tests/test_tools.py @@ -486,10 +486,12 @@ def test_average_questions_distribution(self): cache_results(self.evaluation) evaluation_results = get_results(self.evaluation) - question_results = [] - for contribution_result in evaluation_results.contribution_results: - for questionnaire_result in contribution_result.questionnaire_results: - question_results.extend(questionnaire_result.question_results) + question_results = [ + question_result + for contribution_result in evaluation_results.contribution_results + for questionnaire_result in contribution_result.questionnaire_results + for question_result in questionnaire_result.question_results + ] self.assertIsNone(average_grade_questions_distribution(question_results)) self.assertIsNone(average_non_grade_rating_questions_distribution(question_results)) @@ -512,23 +514,11 @@ def test_dropout_questionnaire_excluded_from_distribution(self): make_rating_answer_counters(dropout_question, self.evaluation.general_contribution, [10, 0, 0, 0, 0]) cache_results(self.evaluation) - # Should not raise an AssertionError distribution_with_dropout = calculate_average_distribution(self.evaluation) - self.assertEqual(distribution_without_dropout, distribution_with_dropout) - def test_dropout_questionnaire_with_counts_for_grade_true(self): - dropout_questionnaire = baker.make(Questionnaire, type=Questionnaire.Type.DROPOUT) - # Create a question with counts_for_grade=True (bypassing form validation) - dropout_question = baker.make( - Question, - questionnaire=dropout_questionnaire, - type=QuestionType.GRADE, - counts_for_grade=True, - ) - self.evaluation.general_contribution.questionnaires.add(dropout_questionnaire) - - make_rating_answer_counters(dropout_question, self.evaluation.general_contribution, [1, 0, 0, 0, 0]) + dropout_question.counts_for_grade = True + dropout_question.save() cache_results(self.evaluation) # Should raise AssertionError because dropout questionnaire has counts_for_grade=True diff --git a/evap/static/ts/src/staff-questionnaire-form.ts b/evap/static/ts/src/staff-questionnaire-form.ts index 004c2d253b..42e4775f3e 100644 --- a/evap/static/ts/src/staff-questionnaire-form.ts +++ b/evap/static/ts/src/staff-questionnaire-form.ts @@ -17,25 +17,25 @@ export class StaffQuestionnaireForm { this.initialize(); } - private disableAndUncheckCheckbox = (checkbox: HTMLInputElement) => { + private disableAndUncheck = (checkbox: HTMLInputElement) => { checkbox.checked = false; checkbox.disabled = true; }; - private enableAndCheckCheckbox = (checkbox: HTMLInputElement) => { + private enableAndCheck = (checkbox: HTMLInputElement) => { checkbox.checked = true; checkbox.disabled = false; }; - private disableAllCheckboxes = (checkboxes: NodeListOf) => { + private disableAll = (checkboxes: NodeListOf) => { checkboxes.forEach(checkbox => { - this.disableAndUncheckCheckbox(checkbox as HTMLInputElement); + this.disableAndUncheck(checkbox as HTMLInputElement); }); }; - private enableAllCheckboxes = (checkboxes: NodeListOf) => { + private enableAll = (checkboxes: NodeListOf) => { checkboxes.forEach(checkbox => { - this.enableAndCheckCheckbox(checkbox as HTMLInputElement); + this.enableAndCheck(checkbox as HTMLInputElement); }); }; @@ -49,7 +49,7 @@ export class StaffQuestionnaireForm { const checkboxes = questionTypeCell.querySelectorAll("input[type=checkbox]"); if (questionType === QUESTION_TYPE_TEXT || questionType === QUESTION_TYPE_HEADING) { - this.disableAllCheckboxes(checkboxes); + this.disableAll(checkboxes); return; } @@ -58,13 +58,13 @@ export class StaffQuestionnaireForm { checkboxes.forEach(checkbox => { const checkboxElement = checkbox as HTMLInputElement; if (checkboxElement.classList.contains("counts-for-grade-checkbox")) { - this.disableAndUncheckCheckbox(checkboxElement); + this.disableAndUncheck(checkboxElement); } else { - this.enableAndCheckCheckbox(checkboxElement); + this.enableAndCheck(checkboxElement); } }); } else { - this.enableAllCheckboxes(checkboxes); + this.enableAll(checkboxes); } } }; @@ -85,13 +85,13 @@ export class StaffQuestionnaireForm { } if (selectedType === QUESTIONNAIRE_TYPE_DROPOUT) { - this.disableAndUncheckCheckbox(checkboxElement); + this.disableAndUncheck(checkboxElement); } else { const questionType = saneParseInt(questionTypeSelect.value); if (questionType === QUESTION_TYPE_TEXT || questionType === QUESTION_TYPE_HEADING) { - this.disableAndUncheckCheckbox(checkboxElement); + this.disableAndUncheck(checkboxElement); } else { - this.enableAndCheckCheckbox(checkboxElement); + this.enableAndCheck(checkboxElement); } } }); @@ -122,12 +122,4 @@ export class StaffQuestionnaireForm { }); }); }; - - public registerSelectChangedHandlers = () => { - document.querySelectorAll(".question-type select").forEach(selectElement => { - selectElement.addEventListener("change", this.handleQuestionTypeChange); - }); - - this.questionnaireTypeSelect.addEventListener("change", this.handleQuestionnaireTypeChange); - }; } From 6b5b471f62a6d98887b13daccd1e3aecb976cef0 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 26 Jan 2026 20:23:16 +0100 Subject: [PATCH 49/57] fix migration naming --- ...n_counts_for_grade.py => 0160_question_counts_for_grade.py} | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) rename evap/evaluation/migrations/{0158_question_counts_for_grade.py => 0160_question_counts_for_grade.py} (93%) diff --git a/evap/evaluation/migrations/0158_question_counts_for_grade.py b/evap/evaluation/migrations/0160_question_counts_for_grade.py similarity index 93% rename from evap/evaluation/migrations/0158_question_counts_for_grade.py rename to evap/evaluation/migrations/0160_question_counts_for_grade.py index 96b0953d5b..50b7ff1dd0 100644 --- a/evap/evaluation/migrations/0158_question_counts_for_grade.py +++ b/evap/evaluation/migrations/0160_question_counts_for_grade.py @@ -17,9 +17,8 @@ def set_initial_values(apps, _schema_editor): class Migration(migrations.Migration): - dependencies = [ - ("evaluation", "0157_coursetype_skip_on_automated_import"), + ("evaluation", "0159_semester_cms_name_semester_default_course_end_date"), ] operations = [ From 5ae44bb058da3eec6cca8dbb3da5ee302e9eda75 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 2 Feb 2026 22:16:05 +0100 Subject: [PATCH 50/57] add helper function for question checkbox logic assertions --- evap/staff/tests/test_live.py | 95 ++++++++++++++++++++++------------- 1 file changed, 59 insertions(+), 36 deletions(-) diff --git a/evap/staff/tests/test_live.py b/evap/staff/tests/test_live.py index f41d159c44..695b121bf5 100644 --- a/evap/staff/tests/test_live.py +++ b/evap/staff/tests/test_live.py @@ -191,23 +191,29 @@ def test_question_type_disabling_logic(self): row = self.wait.until(visibility_of_element_located((By.CSS_SELECTOR, "#question_table tbody tr"))) type_select = row.find_element(By.CSS_SELECTOR, "select[id$='-type']") - self.select_tom_select_option(type_select, str(QuestionType.TEXT)) - self.assertTrue( - row.find_element(By.CSS_SELECTOR, "input[id$='-allows_additional_textanswers']").get_attribute("disabled") + self.assert_question_type_controls( + row, + type_select, + QuestionType.TEXT, + allows_additional_textanswers_disabled=True, + counts_for_grade_disabled=True, ) - self.assertTrue(row.find_element(By.CSS_SELECTOR, "input[id$='-counts_for_grade']").get_attribute("disabled")) - self.select_tom_select_option(type_select, str(QuestionType.POSITIVE_LIKERT)) - self.assertFalse( - row.find_element(By.CSS_SELECTOR, "input[id$='-allows_additional_textanswers']").get_attribute("disabled") + self.assert_question_type_controls( + row, + type_select, + QuestionType.POSITIVE_LIKERT, + allows_additional_textanswers_disabled=False, + counts_for_grade_disabled=False, ) - self.assertFalse(row.find_element(By.CSS_SELECTOR, "input[id$='-counts_for_grade']").get_attribute("disabled")) - self.select_tom_select_option(type_select, str(QuestionType.HEADING)) - self.assertTrue( - row.find_element(By.CSS_SELECTOR, "input[id$='-allows_additional_textanswers']").get_attribute("disabled") + self.assert_question_type_controls( + row, + type_select, + QuestionType.HEADING, + allows_additional_textanswers_disabled=True, + counts_for_grade_disabled=True, ) - self.assertTrue(row.find_element(By.CSS_SELECTOR, "input[id$='-counts_for_grade']").get_attribute("disabled")) # Part 2: Add New Question self.selenium.find_element(By.CLASS_NAME, "add-row").click() @@ -220,34 +226,28 @@ def test_question_type_disabling_logic(self): ] # the last row is the add row button new_type_select = new_row.find_element(By.CSS_SELECTOR, "select[id$='-type']") - self.select_tom_select_option(new_type_select, str(QuestionType.TEXT)) - self.assertTrue( - new_row.find_element(By.CSS_SELECTOR, "input[id$='-allows_additional_textanswers']").get_attribute( - "disabled" - ) - ) - self.assertTrue( - new_row.find_element(By.CSS_SELECTOR, "input[id$='-counts_for_grade']").get_attribute("disabled") + self.assert_question_type_controls( + new_row, + new_type_select, + QuestionType.TEXT, + allows_additional_textanswers_disabled=True, + counts_for_grade_disabled=True, ) - self.select_tom_select_option(new_type_select, str(QuestionType.POSITIVE_LIKERT)) - self.assertFalse( - new_row.find_element(By.CSS_SELECTOR, "input[id$='-allows_additional_textanswers']").get_attribute( - "disabled" - ) - ) - self.assertFalse( - new_row.find_element(By.CSS_SELECTOR, "input[id$='-counts_for_grade']").get_attribute("disabled") + self.assert_question_type_controls( + new_row, + new_type_select, + QuestionType.POSITIVE_LIKERT, + allows_additional_textanswers_disabled=False, + counts_for_grade_disabled=False, ) - self.select_tom_select_option(new_type_select, str(QuestionType.HEADING)) - self.assertTrue( - new_row.find_element(By.CSS_SELECTOR, "input[id$='-allows_additional_textanswers']").get_attribute( - "disabled" - ) - ) - self.assertTrue( - new_row.find_element(By.CSS_SELECTOR, "input[id$='-counts_for_grade']").get_attribute("disabled") + self.assert_question_type_controls( + new_row, + new_type_select, + QuestionType.HEADING, + allows_additional_textanswers_disabled=True, + counts_for_grade_disabled=True, ) def test_questionnaire_type_disabling_logic(self): @@ -277,6 +277,29 @@ def test_questionnaire_type_disabling_logic(self): def select_tom_select_option(self, select_element, value): self.selenium.execute_script(f"arguments[0].tomselect.setValue('{value}');", select_element) + def assert_question_type_controls( + self, + row, + type_select, + question_type, + *, + allows_additional_textanswers_disabled, + counts_for_grade_disabled, + ): + self.select_tom_select_option(type_select, str(question_type)) + self.assertEqual( + allows_additional_textanswers_disabled, + bool( + row.find_element(By.CSS_SELECTOR, "input[id$='-allows_additional_textanswers']").get_attribute( + "disabled" + ) + ), + ) + self.assertEqual( + counts_for_grade_disabled, + bool(row.find_element(By.CSS_SELECTOR, "input[id$='-counts_for_grade']").get_attribute("disabled")), + ) + class TextAnswerEditLiveTest(LiveServerTest): def test_edit_textanswer_redirect(self): From 7708486c20f3d1db3808a52a544aee9c708b805b Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 9 Feb 2026 19:39:40 +0100 Subject: [PATCH 51/57] move helper for tomselect to LiveServerTest class --- evap/evaluation/tests/tools.py | 3 +++ evap/staff/tests/test_live.py | 7 ++----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/evap/evaluation/tests/tools.py b/evap/evaluation/tests/tools.py index 7ebe0039ca..dd3e3b6ca9 100644 --- a/evap/evaluation/tests/tools.py +++ b/evap/evaluation/tests/tools.py @@ -359,6 +359,9 @@ def setUpClass(cls) -> None: super().setUpClass() cls.selenium.set_window_size(*cls.window_size) + def set_tomselect_value(self, instance: WebElement, value: str) -> None: + self.selenium.execute_script(f"arguments[0].tomselect.setValue('{value}');", instance) + def classes_of_element(element: WebElement) -> list[str]: classes = element.get_attribute("class") diff --git a/evap/staff/tests/test_live.py b/evap/staff/tests/test_live.py index 695b121bf5..8ee3f657b8 100644 --- a/evap/staff/tests/test_live.py +++ b/evap/staff/tests/test_live.py @@ -261,22 +261,19 @@ def test_questionnaire_type_disabling_logic(self): questionnaire_type_select = self.selenium.find_element(By.ID, "id_type") # Change to Dropout - self.select_tom_select_option(questionnaire_type_select, str(Questionnaire.Type.DROPOUT)) + self.set_tomselect_value(questionnaire_type_select, str(Questionnaire.Type.DROPOUT)) self.assertTrue(row.find_element(By.CSS_SELECTOR, "input[id$='-counts_for_grade']").get_attribute("disabled")) self.assertFalse( row.find_element(By.CSS_SELECTOR, "input[id$='-allows_additional_textanswers']").get_attribute("disabled") ) # Change back to Top - self.select_tom_select_option(questionnaire_type_select, str(Questionnaire.Type.TOP)) + self.set_tomselect_value(questionnaire_type_select, str(Questionnaire.Type.TOP)) self.assertFalse(row.find_element(By.CSS_SELECTOR, "input[id$='-counts_for_grade']").get_attribute("disabled")) self.assertFalse( row.find_element(By.CSS_SELECTOR, "input[id$='-allows_additional_textanswers']").get_attribute("disabled") ) - def select_tom_select_option(self, select_element, value): - self.selenium.execute_script(f"arguments[0].tomselect.setValue('{value}');", select_element) - def assert_question_type_controls( self, row, From c301bc321106ca5664658023ccac04e5d1c357ff Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 9 Feb 2026 20:33:52 +0100 Subject: [PATCH 52/57] fix some tests so they look better --- evap/results/tests/test_tools.py | 25 ++++----- evap/staff/tests/test_forms.py | 3 +- evap/staff/tests/test_live.py | 90 +++++++++----------------------- 3 files changed, 37 insertions(+), 81 deletions(-) diff --git a/evap/results/tests/test_tools.py b/evap/results/tests/test_tools.py index 4fce2cfe3b..a7e60e1fb8 100644 --- a/evap/results/tests/test_tools.py +++ b/evap/results/tests/test_tools.py @@ -451,9 +451,7 @@ def test_average_questions_distribution(self): ) counters = [ - *make_rating_answer_counters(grade_question, self.contribution1, [1, 0, 0, 0, 0], False), *make_rating_answer_counters(non_counting_grade_question, self.contribution1, [0, 0, 0, 0, 1], False), - *make_rating_answer_counters(likert_question, self.contribution1, [0, 0, 3, 0, 0], False), *make_rating_answer_counters(non_counting_likert_question, self.contribution1, [0, 0, 0, 0, 3], False), ] RatingAnswerCounter.objects.bulk_create(counters) @@ -468,18 +466,12 @@ def test_average_questions_distribution(self): for question_result in questionnaire_result.question_results ] - grade_distribution = average_grade_questions_distribution(question_results) - self.assertEqual(grade_distribution, (1, 0, 0, 0, 0)) # Only the counting grade question should be included - - non_grade_distribution = average_non_grade_rating_questions_distribution(question_results) - self.assertEqual( - non_grade_distribution, (0, 0, 1, 0, 0) - ) # Only the counting likert question should be included + self.assertIsNone(average_grade_questions_distribution(question_results)) + self.assertIsNone(average_non_grade_rating_questions_distribution(question_results)) - RatingAnswerCounter.objects.all().delete() counters = [ - *make_rating_answer_counters(non_counting_grade_question, self.contribution1, [0, 0, 0, 0, 1], False), - *make_rating_answer_counters(non_counting_likert_question, self.contribution1, [0, 0, 0, 0, 3], False), + *make_rating_answer_counters(grade_question, self.contribution1, [1, 0, 0, 0, 0], False), + *make_rating_answer_counters(likert_question, self.contribution1, [0, 0, 3, 0, 0], False), ] RatingAnswerCounter.objects.bulk_create(counters) @@ -493,8 +485,13 @@ def test_average_questions_distribution(self): for question_result in questionnaire_result.question_results ] - self.assertIsNone(average_grade_questions_distribution(question_results)) - self.assertIsNone(average_non_grade_rating_questions_distribution(question_results)) + grade_distribution = average_grade_questions_distribution(question_results) + self.assertEqual(grade_distribution, (1, 0, 0, 0, 0)) # Only the counting grade question should be included + + non_grade_distribution = average_non_grade_rating_questions_distribution(question_results) + self.assertEqual( + non_grade_distribution, (0, 0, 1, 0, 0) + ) # Only the counting likert question should be included def test_dropout_questionnaire_excluded_from_distribution(self): make_rating_answer_counters(self.question_grade, self.general_contribution, [0, 0, 0, 0, 10]) diff --git a/evap/staff/tests/test_forms.py b/evap/staff/tests/test_forms.py index 884b7b362a..c9a58c604c 100644 --- a/evap/staff/tests/test_forms.py +++ b/evap/staff/tests/test_forms.py @@ -1159,10 +1159,9 @@ def test_allows_additional_textanswers_persistence_after_type_change(self): even after changing the question type from TEXT/HEADING to a Likert type. Regression test for #2539. """ - questionnaire = baker.make(Questionnaire, type=Questionnaire.Type.TOP) question = baker.make( Question, - questionnaire=questionnaire, + questionnaire__type=Questionnaire.Type.TOP, type=QuestionType.TEXT, allows_additional_textanswers=False, ) diff --git a/evap/staff/tests/test_live.py b/evap/staff/tests/test_live.py index 8ee3f657b8..2df4d06804 100644 --- a/evap/staff/tests/test_live.py +++ b/evap/staff/tests/test_live.py @@ -181,6 +181,21 @@ def test_collapse_without_editor_approved(self) -> None: class QuestionnaireFormLiveTest(LiveServerTest): def test_question_type_disabling_logic(self): + def assert_type_allows(row, type_select, question_type, additional_textanswers, counts_for_grade): + self.set_tomselect_value(type_select, str(question_type)) + self.assertNotEqual( + additional_textanswers, + bool( + row.find_element(By.CSS_SELECTOR, "input[id$='-allows_additional_textanswers']").get_attribute( + "disabled" + ) + ), + ) + self.assertNotEqual( + counts_for_grade, + bool(row.find_element(By.CSS_SELECTOR, "input[id$='-counts_for_grade']").get_attribute("disabled")), + ) + questionnaire = baker.make(Questionnaire, type=Questionnaire.Type.TOP) baker.make(Question, questionnaire=questionnaire, type=QuestionType.POSITIVE_LIKERT) @@ -191,29 +206,11 @@ def test_question_type_disabling_logic(self): row = self.wait.until(visibility_of_element_located((By.CSS_SELECTOR, "#question_table tbody tr"))) type_select = row.find_element(By.CSS_SELECTOR, "select[id$='-type']") - self.assert_question_type_controls( - row, - type_select, - QuestionType.TEXT, - allows_additional_textanswers_disabled=True, - counts_for_grade_disabled=True, - ) - - self.assert_question_type_controls( - row, - type_select, - QuestionType.POSITIVE_LIKERT, - allows_additional_textanswers_disabled=False, - counts_for_grade_disabled=False, - ) - - self.assert_question_type_controls( - row, - type_select, - QuestionType.HEADING, - allows_additional_textanswers_disabled=True, - counts_for_grade_disabled=True, + assert_type_allows(row, type_select, QuestionType.TEXT, additional_textanswers=False, counts_for_grade=False) + assert_type_allows( + row, type_select, QuestionType.POSITIVE_LIKERT, additional_textanswers=True, counts_for_grade=True ) + assert_type_allows(row, type_select, QuestionType.HEADING, additional_textanswers=False, counts_for_grade=False) # Part 2: Add New Question self.selenium.find_element(By.CLASS_NAME, "add-row").click() @@ -226,28 +223,14 @@ def test_question_type_disabling_logic(self): ] # the last row is the add row button new_type_select = new_row.find_element(By.CSS_SELECTOR, "select[id$='-type']") - self.assert_question_type_controls( - new_row, - new_type_select, - QuestionType.TEXT, - allows_additional_textanswers_disabled=True, - counts_for_grade_disabled=True, + assert_type_allows( + new_row, new_type_select, QuestionType.TEXT, additional_textanswers=False, counts_for_grade=False ) - - self.assert_question_type_controls( - new_row, - new_type_select, - QuestionType.POSITIVE_LIKERT, - allows_additional_textanswers_disabled=False, - counts_for_grade_disabled=False, + assert_type_allows( + new_row, new_type_select, QuestionType.POSITIVE_LIKERT, additional_textanswers=True, counts_for_grade=True ) - - self.assert_question_type_controls( - new_row, - new_type_select, - QuestionType.HEADING, - allows_additional_textanswers_disabled=True, - counts_for_grade_disabled=True, + assert_type_allows( + new_row, new_type_select, QuestionType.HEADING, additional_textanswers=False, counts_for_grade=False ) def test_questionnaire_type_disabling_logic(self): @@ -274,29 +257,6 @@ def test_questionnaire_type_disabling_logic(self): row.find_element(By.CSS_SELECTOR, "input[id$='-allows_additional_textanswers']").get_attribute("disabled") ) - def assert_question_type_controls( - self, - row, - type_select, - question_type, - *, - allows_additional_textanswers_disabled, - counts_for_grade_disabled, - ): - self.select_tom_select_option(type_select, str(question_type)) - self.assertEqual( - allows_additional_textanswers_disabled, - bool( - row.find_element(By.CSS_SELECTOR, "input[id$='-allows_additional_textanswers']").get_attribute( - "disabled" - ) - ), - ) - self.assertEqual( - counts_for_grade_disabled, - bool(row.find_element(By.CSS_SELECTOR, "input[id$='-counts_for_grade']").get_attribute("disabled")), - ) - class TextAnswerEditLiveTest(LiveServerTest): def test_edit_textanswer_redirect(self): From f6131b4b38aacd8cfb921f7a410679024adc1a7b Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 9 Feb 2026 20:42:44 +0100 Subject: [PATCH 53/57] remove duplicated test --- evap/results/tests/test_tools.py | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/evap/results/tests/test_tools.py b/evap/results/tests/test_tools.py index a7e60e1fb8..590c5a7076 100644 --- a/evap/results/tests/test_tools.py +++ b/evap/results/tests/test_tools.py @@ -419,23 +419,6 @@ def test_calculate_average_course_distribution(self): self.assertEqual(distribution[3], 0) self.assertEqual(distribution[4], 0) - def test_grade_calculation_with_non_counting_questions(self): - non_counting_question = baker.make( - Question, questionnaire=self.questionnaire, type=QuestionType.GRADE, counts_for_grade=False - ) - - counters = [ - *make_rating_answer_counters(self.question_grade, self.contribution1, [10, 10, 0, 0, 0], False), - *make_rating_answer_counters(non_counting_question, self.contribution1, [0, 0, 0, 0, 10], False), - ] - RatingAnswerCounter.objects.bulk_create(counters) - - cache_results(self.evaluation) - - average_grade = distribution_to_grade(calculate_average_distribution(self.evaluation)) - - self.assertAlmostEqual(average_grade, 1.5) - def test_average_questions_distribution(self): grade_question = baker.make( Question, questionnaire=self.questionnaire, type=QuestionType.GRADE, counts_for_grade=True @@ -487,11 +470,13 @@ def test_average_questions_distribution(self): grade_distribution = average_grade_questions_distribution(question_results) self.assertEqual(grade_distribution, (1, 0, 0, 0, 0)) # Only the counting grade question should be included + self.assertAlmostEqual(distribution_to_grade(grade_distribution), 1.0) non_grade_distribution = average_non_grade_rating_questions_distribution(question_results) self.assertEqual( non_grade_distribution, (0, 0, 1, 0, 0) ) # Only the counting likert question should be included + self.assertAlmostEqual(distribution_to_grade(non_grade_distribution), 3.0) def test_dropout_questionnaire_excluded_from_distribution(self): make_rating_answer_counters(self.question_grade, self.general_contribution, [0, 0, 0, 0, 10]) From 2f88eeefe85bc81ee372bdda9c0a130902fd83d4 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 16 Feb 2026 19:50:48 +0100 Subject: [PATCH 54/57] UX improvements and code cleanup --- .../static/ts/src/staff-questionnaire-form.ts | 28 +++++-------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/evap/static/ts/src/staff-questionnaire-form.ts b/evap/static/ts/src/staff-questionnaire-form.ts index 42e4775f3e..db3fd46494 100644 --- a/evap/static/ts/src/staff-questionnaire-form.ts +++ b/evap/static/ts/src/staff-questionnaire-form.ts @@ -23,7 +23,10 @@ export class StaffQuestionnaireForm { }; private enableAndCheck = (checkbox: HTMLInputElement) => { - checkbox.checked = true; + // do not override currently input user selection, if there is no need to + if (checkbox.disabled) { + checkbox.checked = true; + } checkbox.disabled = false; }; @@ -99,27 +102,10 @@ export class StaffQuestionnaireForm { private initialize = () => { // Initialize the state of all checkboxes based on current question types and questionnaire type - const questionnaireType = saneParseInt(this.questionnaireTypeSelect.value); - - document.querySelectorAll(".question-type").forEach(questionTypeCell => { - const questionTypeSelect = selectOrError("select", questionTypeCell); - const questionType = saneParseInt(questionTypeSelect.value); - - if (questionTypeSelect.value === "") { - return; - } - - const checkboxes = questionTypeCell.querySelectorAll("input[type=checkbox]"); - const isTextOrHeading = questionType === QUESTION_TYPE_TEXT || questionType === QUESTION_TYPE_HEADING; - - checkboxes.forEach(checkbox => { - const checkboxElement = checkbox as HTMLInputElement; - const isCountsForGrade = checkboxElement.classList.contains("counts-for-grade-checkbox"); + const questionTypeSelects = this.questionTable.querySelectorAll("td.question-type select"); - if (isTextOrHeading || (questionnaireType === QUESTIONNAIRE_TYPE_DROPOUT && isCountsForGrade)) { - checkboxElement.disabled = true; - } - }); + questionTypeSelects.forEach(select => { + select.dispatchEvent(new Event("change", { bubbles: true })); }); }; } From 280cd081cccebb600482e4f1897bed0c338754b1 Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 16 Feb 2026 19:53:55 +0100 Subject: [PATCH 55/57] fix migration naming --- ...on_counts_for_grade.py => 0161_question_counts_for_grade.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename evap/evaluation/migrations/{0160_question_counts_for_grade.py => 0161_question_counts_for_grade.py} (93%) diff --git a/evap/evaluation/migrations/0160_question_counts_for_grade.py b/evap/evaluation/migrations/0161_question_counts_for_grade.py similarity index 93% rename from evap/evaluation/migrations/0160_question_counts_for_grade.py rename to evap/evaluation/migrations/0161_question_counts_for_grade.py index 50b7ff1dd0..791d1804e4 100644 --- a/evap/evaluation/migrations/0160_question_counts_for_grade.py +++ b/evap/evaluation/migrations/0161_question_counts_for_grade.py @@ -18,7 +18,7 @@ def set_initial_values(apps, _schema_editor): class Migration(migrations.Migration): dependencies = [ - ("evaluation", "0159_semester_cms_name_semester_default_course_end_date"), + ("evaluation", "0160_evaluation_staff_notes"), ] operations = [ From d1aa2b466cd27d83cc124e2d42c04914eb3e954d Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 30 Mar 2026 20:51:30 +0200 Subject: [PATCH 56/57] fix naming --- .../static/ts/src/staff-questionnaire-form.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/evap/static/ts/src/staff-questionnaire-form.ts b/evap/static/ts/src/staff-questionnaire-form.ts index db3fd46494..30dc41c187 100644 --- a/evap/static/ts/src/staff-questionnaire-form.ts +++ b/evap/static/ts/src/staff-questionnaire-form.ts @@ -22,23 +22,23 @@ export class StaffQuestionnaireForm { checkbox.disabled = true; }; - private enableAndCheck = (checkbox: HTMLInputElement) => { - // do not override currently input user selection, if there is no need to + private enableAndInit = (checkbox: HTMLInputElement, initialValue: boolean) => { + // do not override current input user selection, if there is no need to if (checkbox.disabled) { - checkbox.checked = true; + checkbox.checked = initialValue; } checkbox.disabled = false; }; - private disableAll = (checkboxes: NodeListOf) => { + private disableAndUncheckAll = (checkboxes: NodeListOf) => { checkboxes.forEach(checkbox => { this.disableAndUncheck(checkbox as HTMLInputElement); }); }; - private enableAll = (checkboxes: NodeListOf) => { + private enableAndInitAll = (checkboxes: NodeListOf, initialValue: boolean) => { checkboxes.forEach(checkbox => { - this.enableAndCheck(checkbox as HTMLInputElement); + this.enableAndInit(checkbox as HTMLInputElement, initialValue); }); }; @@ -52,7 +52,7 @@ export class StaffQuestionnaireForm { const checkboxes = questionTypeCell.querySelectorAll("input[type=checkbox]"); if (questionType === QUESTION_TYPE_TEXT || questionType === QUESTION_TYPE_HEADING) { - this.disableAll(checkboxes); + this.disableAndUncheckAll(checkboxes); return; } @@ -63,11 +63,11 @@ export class StaffQuestionnaireForm { if (checkboxElement.classList.contains("counts-for-grade-checkbox")) { this.disableAndUncheck(checkboxElement); } else { - this.enableAndCheck(checkboxElement); + this.enableAndInit(checkboxElement, true); } }); } else { - this.enableAll(checkboxes); + this.enableAndInitAll(checkboxes, true); } } }; @@ -94,7 +94,7 @@ export class StaffQuestionnaireForm { if (questionType === QUESTION_TYPE_TEXT || questionType === QUESTION_TYPE_HEADING) { this.disableAndUncheck(checkboxElement); } else { - this.enableAndCheck(checkboxElement); + this.enableAndInit(checkboxElement, true); } } }); From ebf01a24d2014ae8bba284ee58ffbfe21c30045c Mon Sep 17 00:00:00 2001 From: Josef Valentin Date: Mon, 30 Mar 2026 21:00:15 +0200 Subject: [PATCH 57/57] disable checkboxes with template and add test --- evap/staff/templates/staff_questionnaire_form.html | 4 ++-- evap/staff/tests/test_views.py | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/evap/staff/templates/staff_questionnaire_form.html b/evap/staff/templates/staff_questionnaire_form.html index 349e927e59..a03b7fb8d5 100644 --- a/evap/staff/templates/staff_questionnaire_form.html +++ b/evap/staff/templates/staff_questionnaire_form.html @@ -57,12 +57,12 @@
{% translate 'Questions' %}
{% include 'bootstrap_form_field_widget.html' with field=form_element.type %}
+ type="checkbox" data-keep="true" {% if form_element.allows_additional_textanswers.value %} checked{% endif %}{% if form_element.allows_additional_textanswers.field.disabled %} disabled{% endif %}/>
+ type="checkbox" data-keep="true" {% if form_element.counts_for_grade.value %} checked{% endif %}{% if form_element.counts_for_grade.field.disabled %} disabled{% endif %}/>
diff --git a/evap/staff/tests/test_views.py b/evap/staff/tests/test_views.py index e4bfe778fc..8e1738743b 100644 --- a/evap/staff/tests/test_views.py +++ b/evap/staff/tests/test_views.py @@ -3307,6 +3307,12 @@ def test_allowed_type_changes_on_used_questionnaire(self): "Dropout questionnaires should not be changeable to different types", ) + def test_used_questionnaire_disables_custom_checkboxes(self): + page = self.app.get(self.url, user=self.manager) + form = page.forms["questionnaire-form"] + self.assertIn("disabled", form["questions-0-allows_additional_textanswers"].attrs) + self.assertIn("disabled", form["questions-0-counts_for_grade"].attrs) + class TestQuestionnaireViewView(WebTestStaffModeWith200Check): @classmethod