diff --git a/deployment/localsettings.template.py b/deployment/localsettings.template.py index a55f0ca5d..71e27dd76 100644 --- a/deployment/localsettings.template.py +++ b/deployment/localsettings.template.py @@ -53,3 +53,5 @@ "de": mark_safe("Deine Teilnahme am Evaluationsprojekt wird helfen. Evaluiere also jetzt!"), "en": mark_safe("Your participation in the evaluation helps, so evaluate now!"), } +# Questionnaires automatically added to exam evaluations +EXAM_QUESTIONNAIRE_IDS = [111] diff --git a/evap/development/fixtures/test_data.json b/evap/development/fixtures/test_data.json index 0203e18e6..6af5c5b40 100644 --- a/evap/development/fixtures/test_data.json +++ b/evap/development/fixtures/test_data.json @@ -656,6 +656,24 @@ "is_locked": false } }, +{ + "model": "evaluation.questionnaire", + "pk": 111, + "fields": { + "type": 10, + "name_de": "Klausur", + "name_en": "Exam", + "description_de": "", + "description_en": "", + "public_name_de": "Klausur", + "public_name_en": "Exam", + "teaser_de": "", + "teaser_en": "", + "order": 62, + "visibility": 1, + "is_locked": false + } +}, { "model": "evaluation.program", "pk": 1, @@ -21784,6 +21802,18 @@ "type": 10 } }, +{ + "model": "evaluation.question", + "pk": 478, + "fields": { + "order": 1, + "questionnaire": 111, + "text_de": "Wie fandest du die Klausur?", + "text_en": "How did you like the exam?", + "allows_additional_textanswers": true, + "type": 6 + } +}, { "model": "evaluation.ratinganswercounter", "pk": "0009be0e-4a00-4f89-82b7-9733ff0fe35f", diff --git a/evap/evaluation/models.py b/evap/evaluation/models.py index c7e4881fd..6bd54928a 100644 --- a/evap/evaluation/models.py +++ b/evap/evaluation/models.py @@ -4,7 +4,7 @@ from collections import defaultdict from collections.abc import Collection, Container, Iterable, Sequence from dataclasses import dataclass -from datetime import date, datetime, timedelta +from datetime import date, datetime, time, timedelta from enum import Enum, auto from functools import partial from numbers import Real @@ -470,6 +470,30 @@ class State(models.IntegerChoices): verbose_name=_("wait for grade upload before publishing"), default=True ) + @property + def has_exam_evaluation(self): + return self.course.evaluations.filter(name_de="Klausur", name_en="Exam").exists() + + @property + def earliest_possible_exam_date(self): + return self.vote_start_datetime.date() + timedelta(days=1) + + @transaction.atomic + def create_exam_evaluation(self, exam_date: date): + self.weight = 9 + self.vote_end_date = exam_date - timedelta(days=1) + self.save() + exam_evaluation = Evaluation(course=self.course, name_de="Klausur", name_en="Exam", weight=1, is_rewarded=False) + exam_evaluation.vote_start_datetime = datetime.combine(exam_date + timedelta(days=1), time(8, 0)) + exam_evaluation.vote_end_date = exam_date + timedelta(days=3) + exam_evaluation.save() + + exam_evaluation.participants.set(self.participants.all()) + for contribution in self.contributions.exclude(contributor=None): + exam_evaluation.contributions.create(contributor=contribution.contributor) + exam_evaluation.general_contribution.questionnaires.set(settings.EXAM_QUESTIONNAIRE_IDS) + exam_evaluation.save() + class TextAnswerReviewState(Enum): do_not_call_in_templates = True # pylint: disable=invalid-name NO_TEXTANSWERS = auto() diff --git a/evap/evaluation/templates/base.html b/evap/evaluation/templates/base.html index 9cc581b22..d48debe48 100644 --- a/evap/evaluation/templates/base.html +++ b/evap/evaluation/templates/base.html @@ -211,5 +211,18 @@ {% block additional_javascript %}{% endblock %} + + diff --git a/evap/evaluation/tests/tools.py b/evap/evaluation/tests/tools.py index 85e776b09..cd6e6c6e8 100644 --- a/evap/evaluation/tests/tools.py +++ b/evap/evaluation/tests/tools.py @@ -1,5 +1,6 @@ import functools import os +import re from collections.abc import Sequence from contextlib import contextmanager from datetime import timedelta @@ -243,11 +244,17 @@ def assert_no_database_modifications(*args, **kwargs): with CaptureQueriesContext(conn): yield + last_login_pattern = re.compile( + r"""UPDATE "evaluation_userprofile" SET "last_login" = '\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{6}'::timestamp WHERE "evaluation_userprofile"\."id" = \d+""" + ) for query in conn.queries_log: if ( query["sql"].startswith('INSERT INTO "testing_cache_sessions"') or query["sql"].startswith('UPDATE "testing_cache_sessions"') or query["sql"].startswith('DELETE FROM "testing_cache_sessions"') + # Check that regex matches the last login update query + or last_login_pattern.match(query["sql"]) + # UPDATE "evaluation_userprofile" SET "last_login" = '2024-10-07T18:23:56.914156'::timestamp WHERE "evaluation_userprofile"."id" = 75 ): # These queries are caused by interacting with the test-app (self.app.get()), since that opens a session. # That's not what we want to test for here diff --git a/evap/settings.py b/evap/settings.py index 5a7cd7b61..77c5cd764 100644 --- a/evap/settings.py +++ b/evap/settings.py @@ -107,6 +107,8 @@ # Amount of hours in which participant will be warned EVALUATION_END_WARNING_PERIOD = 5 +# Questionnaires automatically added to exam evaluations +EXAM_QUESTIONNAIRE_IDS: list[int] = [] ### Installation specific settings diff --git a/evap/staff/templates/staff_semester_view.html b/evap/staff/templates/staff_semester_view.html index 1a23c9354..ca25aaa59 100644 --- a/evap/staff/templates/staff_semester_view.html +++ b/evap/staff/templates/staff_semester_view.html @@ -374,6 +374,13 @@

{{ semester.name }}

+ {% for evaluation in evaluations %} + {# separate forms for each modal since we want separate date-selection inputs because each exam_creation_modal needs its own exam date input field. #} +
+ {% csrf_token %} +
+ {% endfor %} +
{% csrf_token %}
@@ -466,6 +473,7 @@

{{ semester.name }}

{% endif %} +
@@ -546,7 +554,7 @@

{{ semester.name }}

href="{% url 'staff:course_copy' course.id %}" title="{% translate 'Copy course' %}"> - + {% endif %} {% if course.can_be_deleted_by_manager %} diff --git a/evap/staff/templates/staff_semester_view_evaluation.html b/evap/staff/templates/staff_semester_view_evaluation.html index 5f35f2d5d..386831790 100644 --- a/evap/staff/templates/staff_semester_view_evaluation.html +++ b/evap/staff/templates/staff_semester_view_evaluation.html @@ -187,6 +187,26 @@ + {% if not evaluation.has_exam %} + + {% translate 'Create exam evaluation' %} + {% translate 'Create exam evaluation' %} + + {% blocktranslate trimmed %} + Create an exam evaluation based on this evaluation. This will copy all the participants and contributors from the original evaluation. It will set the weight of the original evaluation to 9 and its end date will be set to the day before the exam. + {% endblocktranslate %} + +
+ +
+ + + +
+ {% endif %} {% endif %} {% if request.user.is_manager %} diff --git a/evap/staff/tests/test_views.py b/evap/staff/tests/test_views.py index 85de84980..6e16b36ad 100644 --- a/evap/staff/tests/test_views.py +++ b/evap/staff/tests/test_views.py @@ -1960,6 +1960,72 @@ def test_evaluation_copy(self): self.assertEqual(copied_evaluation.contributions.count(), 4) +@override_settings(EXAM_QUESTIONNAIRE_IDS=[111]) +class TestEvaluationExamCreation(WebTestStaffMode): + csrf_checks = False + url = reverse("staff:create_exam_evaluation") + + @classmethod + def setUpTestData(cls): + cls.manager = make_manager() + # We need to set the managers language to avoid a database update, when no language is set + cls.manager.language = "en" + cls.manager.save() + cls.course = baker.make(Course) + vote_start_datetime = datetime.datetime.now() - datetime.timedelta(days=50) + cls.evaluation = baker.make(Evaluation, course=cls.course, vote_start_datetime=vote_start_datetime) + cls.evaluation.participants.set(baker.make(UserProfile, _quantity=3)) + cls.contributions = baker.make( + Contribution, evaluation=cls.evaluation, _fill_optional=["contributor"], _quantity=3, _bulk_create=True + ) + cls.exam_date = datetime.date.today() + datetime.timedelta(days=10) + cls.params = {"evaluation_id": cls.evaluation.pk, "exam_date": cls.exam_date} + cls.exam_questionnaire = baker.make(Questionnaire, pk=111) + + def test_create_exam_evaluation(self): + self.app.post(self.url, user=self.manager, status=200, params=self.params) + self.assertEqual(Evaluation.objects.count(), 2) + exam_evaluation = Evaluation.objects.exclude(pk=self.evaluation.pk).get() + self.assertEqual(exam_evaluation.contributions.count(), self.evaluation.contributions.count()) + self.assertEqual( + exam_evaluation.vote_start_datetime, + datetime.datetime.combine(self.exam_date + datetime.timedelta(days=1), datetime.time(8, 0)), + ) + self.assertEqual(exam_evaluation.vote_end_date, self.exam_date + datetime.timedelta(days=3)) + self.assertEqual(exam_evaluation.name_de, "Klausur") + self.assertEqual(exam_evaluation.name_en, "Exam") + self.assertEqual(exam_evaluation.course, self.evaluation.course) + self.assertQuerySetEqual(exam_evaluation.participants.all(), self.evaluation.participants.all()) + self.assertEqual(exam_evaluation.weight, 1) + + evaluation = Evaluation.objects.get(pk=self.evaluation.pk) + self.assertEqual(evaluation.weight, 9) + + def test_exam_evaluation_for_single_result(self): + self.evaluation.is_single_result = True + self.evaluation.save() + with assert_no_database_modifications(): + self.app.post(self.url, user=self.manager, status=400, params=self.params) + + def test_exam_evaluation_for_already_existing_exam_evaluation(self): + baker.make(Evaluation, course=self.course, name_en="Exam", name_de="Klausur") + self.assertTrue(self.evaluation.has_exam_evaluation) + with assert_no_database_modifications(): + self.app.post(self.url, user=self.manager, status=400, params=self.params) + + def test_exam_evaluation_with_wrong_date(self): + self.evaluation.vote_start_datetime = datetime.datetime.now() + datetime.timedelta(days=100) + self.evaluation.vote_end_date = datetime.date.today() + datetime.timedelta(days=150) + self.evaluation.save() + with assert_no_database_modifications(): + self.app.post(self.url, user=self.manager, status=400, params=self.params) + + def test_exam_evaluation_with_missing_date(self): + self.params.pop("exam_date") + with assert_no_database_modifications(): + self.app.post(self.url, user=self.manager, status=400, params=self.params) + + class TestCourseCopyView(WebTestStaffMode): @classmethod def setUpTestData(cls): diff --git a/evap/staff/urls.py b/evap/staff/urls.py index 5b9acf09a..256a8438b 100644 --- a/evap/staff/urls.py +++ b/evap/staff/urls.py @@ -34,6 +34,7 @@ path("evaluation//copy", views.evaluation_copy, name="evaluation_copy"), path("evaluation//email", views.evaluation_email, name="evaluation_email"), path("evaluation//preview", views.evaluation_preview, name="evaluation_preview"), + path("evaluation/create_exam_evaluation", views.create_exam_evaluation, name="create_exam_evaluation"), path("evaluation//person_management", views.evaluation_person_management, name="evaluation_person_management"), path("evaluation//login_key_export", views.evaluation_login_key_export, name="evaluation_login_key_export"), path("semester//evaluation/operation", views.evaluation_operation, name="evaluation_operation"), diff --git a/evap/staff/views.py b/evap/staff/views.py index 01cf657ee..8f94915f8 100644 --- a/evap/staff/views.py +++ b/evap/staff/views.py @@ -1093,6 +1093,34 @@ def course_copy(request, course_id): ) +@require_POST +@manager_required +def create_exam_evaluation(request: HttpRequest) -> HttpResponse: + evaluation = get_object_from_dict_pk_entry_or_logged_40x(Evaluation, request.POST, "evaluation_id") + if evaluation.is_single_result: + raise SuspiciousOperation("Creating an exam evaluation for a single result evaluation is not allowed") + + if evaluation.has_exam_evaluation: + raise SuspiciousOperation("An exam evaluation already exists for this course") + + exam_date_string = request.POST.get("exam_date") + if not exam_date_string: + return HttpResponseBadRequest("Exam date missing.") + try: + exam_date = datetime.strptime(exam_date_string, "%Y-%m-%d").date() + except ValueError: + return HttpResponseBadRequest("Exam date invalid.") + + if exam_date < evaluation.earliest_possible_exam_date: + raise SuspiciousOperation( + "The end date of the main evaluation would be before its start date. No exam evaluation was created." + ) + + evaluation.create_exam_evaluation(exam_date) + messages.success(request, _("Successfully created exam evaluation.")) + return HttpResponse() # 200 OK + + @manager_required class CourseEditView(SuccessMessageMixin, UpdateView): model = Course