Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Adds shortcut button to add exam evaluation #2050

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions deployment/localsettings.template.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@

# Make apache work when DEBUG == False
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]

# Questionnaires automatically added to exam evaluations
EXAM_QUESTIONNAIRE_IDS = [111]
30 changes: 30 additions & 0 deletions evap/development/fixtures/test_data.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.degree",
"pk": 1,
Expand Down Expand Up @@ -2704,6 +2722,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",
Expand Down
37 changes: 36 additions & 1 deletion evap/evaluation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import uuid
from collections import defaultdict
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from datetime import date, datetime, time, timedelta
from enum import Enum, auto
from numbers import Real

Expand All @@ -18,6 +18,7 @@
from django.db import IntegrityError, models, transaction
from django.db.models import CheckConstraint, Count, F, Manager, OuterRef, Q, Subquery, Value
from django.db.models.functions import Coalesce, Lower, NullIf, TruncDate
from django.db.models.query import QuerySet
from django.dispatch import Signal, receiver
from django.template import Context, Template
from django.template.defaultfilters import linebreaksbr
Expand Down Expand Up @@ -443,6 +444,40 @@ class State:
verbose_name=_("wait for grade upload before publishing"), default=True
)

@property
def has_exam(self):
return self.course.evaluations.filter(name_de="Klausur", name_en="Exam").exists()

@transaction.atomic
def create_exam_evaluation(
self,
exam_date: datetime,
evaluation_end_date: datetime,
FSadrieh marked this conversation as resolved.
Show resolved Hide resolved
):
def _set_exam_evaluation_attributes(
FSadrieh marked this conversation as resolved.
Show resolved Hide resolved
exam_evaluation: Evaluation,
exam_date: date,
participants: QuerySet["UserProfile"],
eval_contributions: QuerySet["Contribution"],
):
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(participants)
for contribution in eval_contributions:
exam_evaluation.contributions.create(contributor=contribution.contributor)
exam_evaluation.general_contribution.questionnaires.set(settings.EXAM_QUESTIONNAIRE_IDS)
exam_evaluation.save()

self.weight = 9
self.vote_end_date = evaluation_end_date
self.save()
participants = self.participants.all()
eval_contributions = self.contributions.exclude(contributor=None)
exam_evaluation = Evaluation(course=self.course, name_de="Klausur", name_en="Exam", weight=1, is_rewarded=False)
_set_exam_evaluation_attributes(exam_evaluation, exam_date, participants, eval_contributions)


class TextAnswerReviewState(Enum):
do_not_call_in_templates = True # pylint: disable=invalid-name
NO_TEXTANSWERS = auto()
Expand Down
2 changes: 2 additions & 0 deletions evap/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,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

Expand Down
9 changes: 8 additions & 1 deletion evap/staff/templates/staff_semester_view.html
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,12 @@ <h3 class="m-0 me-1">{{ semester.name }}</h3>
</div>
</div>

{% for evaluation in evaluations %}
<form id="exam_creation_form_{{ evaluation.id }}" reload-on-success method="post" action="{% url 'staff:create_exam_evaluation' %}">
Comment on lines +380 to +381
Copy link
Member

Choose a reason for hiding this comment

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

Given that except for the id, all these forms are equal, we really only need one

{% csrf_token %}
</form>
{% endfor %}

<form id="evaluation-deletion-form" custom-success method="POST" action="{% url 'staff:evaluation_delete' %}">
{% csrf_token %}
</form>
Expand Down Expand Up @@ -469,6 +475,7 @@ <h3 class="m-0 me-1">{{ semester.name }}</h3>
{% endif %}
</form>
</div>

<div class="tab-pane" id="courses" role="tabpanel">
<div class="row align-items-center mb-3">
<div class="col-9">
Expand Down Expand Up @@ -549,7 +556,7 @@ <h3 class="m-0 me-1">{{ semester.name }}</h3>
href="{% url 'staff:course_copy' course.id %}"
title="{% translate 'Copy course' %}">
<span class="fas fa-copy"></span>
</a>
</a>
{% endif %}
{% if course.can_be_deleted_by_manager %}
<confirmation-modal type="submit" form="course-deletion-form" name="course_id" value="{{ course.id }}" confirm-button-class="btn-danger">
Expand Down
17 changes: 17 additions & 0 deletions evap/staff/templates/staff_semester_view_evaluation.html
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,23 @@
<a href="{% url 'staff:evaluation_copy' evaluation.id %}" class="btn btn-sm btn-light" data-bs-toggle="tooltip" title="{% translate "Copy" %}">
<span class="fas fa-copy"></span>
</a>
{% if not evaluation.has_exam %}
<confirmation-modal type="submit" id="exam_creation_modal" name="evaluation_id" value="{{ evaluation.id }}" confirm-button-class="btn-primary" form="exam_creation_form_{{ evaluation.id }}">
FSadrieh marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

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

The id of these modals will be duplicated when there is more than one applicable evaluation - I think we don't need the id anyways, in that case let's just remove it

Copy link
Collaborator Author

@FSadrieh FSadrieh Jul 1, 2024

Choose a reason for hiding this comment

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

I do not think we can remove it. Without id it throws: ValueError: "<Evaluation: Database Systems I – Exam>" needs to have a value for field "id" before this many-to-many relationship can be used.

<span slot="title">{% translate 'Create exam evaluation' %}</span>
<span slot="action-text">{% translate 'Create exam evaluation' %}</span>
<span slot="question">
{% blocktranslate %}
FSadrieh marked this conversation as resolved.
Show resolved Hide resolved
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.
FSadrieh marked this conversation as resolved.
Show resolved Hide resolved
{% endblocktranslate %}
</span>
<span slot="extra-inputs">
<label for="exam_date">{% translate 'Exam Date:' %}</label>
<input type="date" id="exam_date" name="exam_date" required form="exam_creation_form_{{ evaluation.id }}">
FSadrieh marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

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

This id will also be duplicated. We could include the evaluation's id, or it should also work to move the input element into the label element, then we don't need the id and for attributes (see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/label). I think I prefer the second approach, we would have to make sure it also works with type="date" though :)

</span>

<button slot="show-button" type="button" class="btn btn-sm btn-light" title="{% translate 'Create exam evaluation' %}" data-bs-placement="top" data-bs-toggle="tooltip"><span class="fas fa-file-pen fa-fw"></span></button>
</confirmation-modal>
{% endif %}
{% endif %}
{% if request.user.is_manager %}
<a href="{% url 'staff:evaluation_email' evaluation.id %}" class="btn btn-sm btn-light" data-bs-toggle="tooltip" data-bs-placement="top" title="{% translate 'Send email' %}">
Expand Down
96 changes: 96 additions & 0 deletions evap/staff/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1924,6 +1924,102 @@ def test_evaluation_copy(self):
self.assertEqual(copied_evaluation.contributions.count(), 4)


class TestEvaluationExamCreation(WebTestStaffMode):
csrf_checks = False
url = reverse("staff:create_exam_evaluation")

@classmethod
def setUpTestData(cls):
cls.manager = make_manager()
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.contributions = baker.make(
Contribution, evaluation=cls.evaluation, _fill_optional=["contributor"], _quantity=3, _bulk_create=True
)
cls.exam_datetime = (datetime.date.today()) + datetime.timedelta(days=10)
cls.semester_overview_url = reverse("staff:semester_view", args=[cls.course.semester.pk])
# Default the evaluation does not have any participants, so we need to add some
cls.evaluation.participants.set(baker.make(UserProfile, _quantity=3))

def test_create_exam_evaluation(self):
self.app.post(
self.url,
user=self.manager,
status=200,
params={"evaluation_id": self.evaluation.pk, "exam_date": self.exam_datetime},
)
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_datetime + datetime.timedelta(days=1), datetime.time(8, 0)),
)
self.assertEqual(exam_evaluation.vote_end_date, self.exam_datetime + 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)
FSadrieh marked this conversation as resolved.
Show resolved Hide resolved

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()
self.app.post(
self.url,
user=self.manager,
status=400,
params={"evaluation_id": self.evaluation.pk, "exam_date": self.exam_datetime},
)
self.assertFalse(self.evaluation.has_exam)

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)
self.app.post(
self.url,
user=self.manager,
status=400,
params={"evaluation_id": self.evaluation.pk, "exam_date": self.exam_datetime},
)

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()
self.app.get("", user=self.manager) # Needed to not get a last login database update

with assert_no_database_modifications():
self.app.post(
self.url,
user=self.manager,
status=200,
params={"evaluation_id": self.evaluation.pk, "exam_date": self.exam_datetime},
)

page = self.app.get(
self.semester_overview_url,
user=self.manager,
status=200,
params={"semester_id": self.evaluation.course.semester.pk},
)
self.assertContains(
page, "The exam date is before the start date of the main evaluation. No exam evaluation was created."
)

def test_exam_evaluation_with_missing_date(self):
self.app.post(
self.url,
user=self.manager,
status=400,
params={"evaluation_id": self.evaluation.pk},
)


class TestCourseCopyView(WebTestStaffMode):
@classmethod
def setUpTestData(cls):
Expand Down
1 change: 1 addition & 0 deletions evap/staff/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
path("evaluation/<int:evaluation_id>/copy", views.evaluation_copy, name="evaluation_copy"),
path("evaluation/<int:evaluation_id>/email", views.evaluation_email, name="evaluation_email"),
path("evaluation/<int:evaluation_id>/preview", views.evaluation_preview, name="evaluation_preview"),
path("evaluation/create_exam_evaluation", views.create_exam_evaluation, name="create_exam_evaluation"),
path("evaluation/<int:evaluation_id>/person_management", views.evaluation_person_management, name="evaluation_person_management"),
path("evaluation/<int:evaluation_id>/login_key_export", views.evaluation_login_key_export, name="evaluation_login_key_export"),
path("semester/<int:semester_id>/evaluation/operation", views.evaluation_operation, name="evaluation_operation"),
Expand Down
32 changes: 31 additions & 1 deletion evap/staff/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from collections import OrderedDict, defaultdict, namedtuple
from collections.abc import Container
from dataclasses import dataclass
from datetime import date, datetime
from datetime import date, datetime, timedelta
from typing import Any, cast

import openpyxl
Expand Down Expand Up @@ -1051,6 +1051,36 @@ def course_copy(request, course_id):
)


@require_POST
@manager_required
def create_exam_evaluation(request):
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:
raise SuspiciousOperation("An exam evaluation already exists for this course")
FSadrieh marked this conversation as resolved.
Show resolved Hide resolved
try:
exam_datetime = request.POST.get("exam_date")
exam_datetime = datetime.combine(datetime.strptime(exam_datetime, "%Y-%m-%d"), datetime.min.time())
except TypeError:
return HttpResponseBadRequest("Exam date missing or invalid.")

evaluation_end_date = exam_datetime - timedelta(days=1)
if evaluation.vote_start_datetime > evaluation_end_date:
messages.error(
request, _("The exam date is before the start date of the main evaluation. No exam evaluation was created.")
)
return HttpResponse()
Copy link
Member

Choose a reason for hiding this comment

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

I don't think the message flow makes sense here (as is reflected in the test for this): The post request will be answered with an empty HTTP 200 OK -- how would a user get that error? To my understanding, they would only see that once they go to a new page, right?

If this error is possible to be triggered by a user, they should get a meaningful error immediately.

(A post-only REST handler setting an error in the messages framework generall seems not really useful to me)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@niklasmohrin we settled to check the date when the user submits the modal, similar to the semester deletion modal (See the new commit). One problem we currently face is the error message disappearing fast as the modal closes on click. How would this feature work best with the confirmation modal?


evaluation.create_exam_evaluation(
exam_date=exam_datetime,
evaluation_end_date=evaluation_end_date,
)
messages.success(request, _("Successfully created exam evaluation."))
return HttpResponse() # 200 OK


@manager_required
class CourseEditView(SuccessMessageMixin, UpdateView):
model = Course
Expand Down
Loading