diff --git a/evap/development/fixtures/test_data.json b/evap/development/fixtures/test_data.json
index 8f32bc96b9..5f48b37df7 100644
--- a/evap/development/fixtures/test_data.json
+++ b/evap/development/fixtures/test_data.json
@@ -120069,8 +120069,8 @@
{
"model": "evaluation.userprofile",
"fields": {
- "password": "pbkdf2_sha256$150000$VhbGuFyU0NsF$LaOk+e0jHdSnobNBx3Zv9+/jeVxWIJuz2IuLVJVgtNk=",
- "last_login": "2020-02-18T13:51:45.323",
+ "password": "pbkdf2_sha256$600000$8dcuxbY2OgmCEEj3nlUQWG$qk5ic3Er9FrkCd3VUjC1DEfxd76LPVKrzaMEWGSaxyA=",
+ "last_login": "2023-07-17T21:33:07.189",
"is_superuser": true,
"email": "evap@institution.example.com",
"title": "",
@@ -131275,6 +131275,302 @@
"html_content": "(English version below)
\r\n\r\n\r\nHallo {{ user.first_name }},
\r\n\r\nes gibt noch nicht überprüfte Textantworten für eine oder mehrere Evaluierungen, bei denen der Evaluierungszeitraum abgelaufen ist und nicht mehr auf Notenveröffentlichungen gewartet werden muss. Bitte überprüfe die Textantworten für diese Evaluierungen möglichst bald:\r\n
\r\n\r\n(Dies ist eine automatisch versendete E-Mail.)
\r\n\r\n
\r\n\r\nDear {{ user.first_name }},
\r\n\r\nthere are text answers not yet reviewed for one or more evaluations where the evaluation period has ended and there is no need to wait for grade publishing. Please review the text answers for these evaluations as soon as possible:\r\n
\r\n\r\n(This is an automated message.)"
}
},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 1,
+ "fields": {
+ "evaluation": 347,
+ "timestamp": "2014-05-07T00:19:49"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 2,
+ "fields": {
+ "evaluation": 347,
+ "timestamp": "2014-05-11T01:33:38"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 3,
+ "fields": {
+ "evaluation": 347,
+ "timestamp": "2014-05-23T06:09:16"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 4,
+ "fields": {
+ "evaluation": 347,
+ "timestamp": "2014-05-03T19:27:37"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 5,
+ "fields": {
+ "evaluation": 347,
+ "timestamp": "2014-05-02T00:11:57"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 6,
+ "fields": {
+ "evaluation": 347,
+ "timestamp": "2014-05-29T16:22:45"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 7,
+ "fields": {
+ "evaluation": 347,
+ "timestamp": "2014-05-16T06:15:26"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 8,
+ "fields": {
+ "evaluation": 347,
+ "timestamp": "2014-05-09T13:41:46"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 9,
+ "fields": {
+ "evaluation": 347,
+ "timestamp": "2014-05-29T13:03:12"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 10,
+ "fields": {
+ "evaluation": 348,
+ "timestamp": "2014-05-02T12:24:13"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 11,
+ "fields": {
+ "evaluation": 348,
+ "timestamp": "2014-05-25T19:09:52"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 12,
+ "fields": {
+ "evaluation": 348,
+ "timestamp": "2014-05-11T23:41:20"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 13,
+ "fields": {
+ "evaluation": 348,
+ "timestamp": "2014-05-30T21:17:46"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 14,
+ "fields": {
+ "evaluation": 348,
+ "timestamp": "2014-05-18T06:08:40"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 15,
+ "fields": {
+ "evaluation": 348,
+ "timestamp": "2014-05-03T18:12:06"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 16,
+ "fields": {
+ "evaluation": 348,
+ "timestamp": "2014-05-11T15:03:02"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 17,
+ "fields": {
+ "evaluation": 348,
+ "timestamp": "2014-05-13T05:31:39"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 18,
+ "fields": {
+ "evaluation": 1503,
+ "timestamp": "2014-05-13T19:12:47"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 19,
+ "fields": {
+ "evaluation": 1577,
+ "timestamp": "2014-05-05T21:46:10"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 20,
+ "fields": {
+ "evaluation": 1577,
+ "timestamp": "2014-05-25T19:28:09"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 21,
+ "fields": {
+ "evaluation": 1577,
+ "timestamp": "2014-05-14T03:40:33"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 22,
+ "fields": {
+ "evaluation": 1577,
+ "timestamp": "2014-05-11T19:45:23"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 23,
+ "fields": {
+ "evaluation": 1577,
+ "timestamp": "2014-05-02T01:32:22"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 24,
+ "fields": {
+ "evaluation": 1577,
+ "timestamp": "2014-05-25T00:39:24"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 25,
+ "fields": {
+ "evaluation": 1577,
+ "timestamp": "2014-05-28T19:24:29"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 26,
+ "fields": {
+ "evaluation": 1577,
+ "timestamp": "2014-05-21T05:43:29"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 27,
+ "fields": {
+ "evaluation": 1577,
+ "timestamp": "2014-05-18T07:03:43"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 28,
+ "fields": {
+ "evaluation": 1600,
+ "timestamp": "2014-05-14T12:47:54"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 29,
+ "fields": {
+ "evaluation": 1600,
+ "timestamp": "2014-05-05T16:04:27"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 30,
+ "fields": {
+ "evaluation": 1600,
+ "timestamp": "2014-05-10T04:58:38"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 31,
+ "fields": {
+ "evaluation": 1673,
+ "timestamp": "2016-07-23T19:19:13"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 32,
+ "fields": {
+ "evaluation": 1673,
+ "timestamp": "2021-05-24T05:33:10"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 33,
+ "fields": {
+ "evaluation": 1673,
+ "timestamp": "2015-10-31T12:39:08"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 34,
+ "fields": {
+ "evaluation": 1682,
+ "timestamp": "2016-09-20T00:45:07"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 35,
+ "fields": {
+ "evaluation": 1682,
+ "timestamp": "2023-07-13T23:17:07"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 36,
+ "fields": {
+ "evaluation": 1682,
+ "timestamp": "2016-04-05T13:11:54"
+ }
+},
+{
+ "model": "evaluation.votetimestamp",
+ "pk": 37,
+ "fields": {
+ "evaluation": 1694,
+ "timestamp": "2017-11-29T01:40:06"
+ }
+},
{
"model": "rewards.rewardpointredemptionevent",
"pk": 1,
diff --git a/evap/evaluation/migrations/0138_votetimestamp.py b/evap/evaluation/migrations/0138_votetimestamp.py
new file mode 100644
index 0000000000..b18c5b2136
--- /dev/null
+++ b/evap/evaluation/migrations/0138_votetimestamp.py
@@ -0,0 +1,25 @@
+# Generated by Django 4.2.3 on 2023-07-17 20:09
+
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("evaluation", "0137_use_more_database_constraints"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="VoteTimestamp",
+ fields=[
+ ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("timestamp", models.DateTimeField(default=django.utils.timezone.now, verbose_name="vote timestamp")),
+ (
+ "evaluation",
+ models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="evaluation.evaluation"),
+ ),
+ ],
+ ),
+ ]
diff --git a/evap/evaluation/models.py b/evap/evaluation/models.py
index fe107ba561..1ec572c36a 100644
--- a/evap/evaluation/models.py
+++ b/evap/evaluation/models.py
@@ -26,6 +26,7 @@
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.safestring import SafeData
+from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_fsm import FSMIntegerField, transition
from django_fsm.signals import post_transition
@@ -2139,3 +2140,8 @@ def send_textanswer_reminder_to_user(cls, user: UserProfile, evaluation_url_tupl
body_params = {"user": user, "evaluation_url_tuples": evaluation_url_tuples}
template = cls.objects.get(name=cls.TEXT_ANSWER_REVIEW_REMINDER)
template.send_to_user(user, {}, body_params, use_cc=False)
+
+
+class VoteTimestamp(models.Model):
+ evaluation = models.ForeignKey(Evaluation, models.CASCADE)
+ timestamp = models.DateTimeField(verbose_name=_("vote timestamp"), default=now)
diff --git a/evap/staff/forms.py b/evap/staff/forms.py
index 6638a96905..e06898b3d0 100644
--- a/evap/staff/forms.py
+++ b/evap/staff/forms.py
@@ -308,6 +308,7 @@ def __init__(self, data=None, *, instance: Course):
"_participant_count",
"_voter_count",
"voters",
+ "votetimestamp",
}
CONTRIBUTION_COPIED_FIELDS = {
diff --git a/evap/staff/templates/staff_semester_view.html b/evap/staff/templates/staff_semester_view.html
index 90aa394092..b65a99fd3e 100644
--- a/evap/staff/templates/staff_semester_view.html
+++ b/evap/staff/templates/staff_semester_view.html
@@ -146,6 +146,7 @@
{% trans 'Export results' %}
{% trans 'Export raw evaluation data' %}
{% trans 'Export participation data' %}
+ {% trans 'Export vote timestamps' %}
{% if not semester.participations_are_archived %}
diff --git a/evap/staff/tests/test_views.py b/evap/staff/tests/test_views.py
index 2ed4a520aa..f9734752e7 100644
--- a/evap/staff/tests/test_views.py
+++ b/evap/staff/tests/test_views.py
@@ -35,6 +35,7 @@
Semester,
TextAnswer,
UserProfile,
+ VoteTimestamp,
)
from evap.evaluation.tests.tools import (
FuzzyInt,
@@ -1304,6 +1305,36 @@ def test_view_downloads_csv_file(self):
self.assertEqual(response.content, expected_content.encode("utf-8"))
+class TestSemesterVoteTimestampsExport(WebTestStaffMode):
+ @classmethod
+ def setUpTestData(cls):
+ cls.manager = make_manager()
+ cls.course_type = baker.make(CourseType, name_en="Type")
+ cls.vote_end_date = datetime.date(2017, 1, 3)
+ cls.evaluation_id = 1
+ cls.timestamp_time = datetime.datetime(2017, 1, 1, 12, 0, 0)
+
+ cls.evaluation = baker.make(
+ Evaluation,
+ course__type=cls.course_type,
+ pk=cls.evaluation_id,
+ vote_end_date=cls.vote_end_date,
+ vote_start_datetime=datetime.datetime.combine(cls.vote_end_date, datetime.time())
+ - datetime.timedelta(days=2),
+ )
+ cls.timestamp = baker.make(VoteTimestamp, evaluation=cls.evaluation, timestamp=cls.timestamp_time)
+
+ def test_view_downloads_csv_file(self):
+ response = self.app.get(
+ reverse("staff:vote_timestamps_export", args=[self.evaluation.course.semester.pk]), user=self.manager
+ )
+ expected_content = (
+ "Evaluation id;Course type;Course degrees;Vote end date;Timestamp\n"
+ + f"{self.evaluation_id};Type;;{self.vote_end_date};{self.timestamp_time}\n"
+ ).encode("utf-8")
+ self.assertEqual(response.content, expected_content)
+
+
class TestLoginKeyExportView(WebTestStaffMode):
@classmethod
def setUpTestData(cls):
diff --git a/evap/staff/urls.py b/evap/staff/urls.py
index f17d4b2356..97603cb9ba 100644
--- a/evap/staff/urls.py
+++ b/evap/staff/urls.py
@@ -18,6 +18,7 @@
path("semester//export", views.semester_export, name="semester_export"),
path("semester//raw_export", views.semester_raw_export, name="semester_raw_export"),
path("semester//participation_export", views.semester_participation_export, name="semester_participation_export"),
+ path("semester//vote_timestamps_export", views.vote_timestamps_export, name="vote_timestamps_export"),
path("semester//assign", views.semester_questionnaire_assign, name="semester_questionnaire_assign"),
path("semester//preparation_reminder", views.semester_preparation_reminder, name="semester_preparation_reminder"),
path("semester//grade_reminder", views.semester_grade_reminder, name="semester_grade_reminder"),
diff --git a/evap/staff/views.py b/evap/staff/views.py
index 3b32dcbfeb..d6f143a7b6 100644
--- a/evap/staff/views.py
+++ b/evap/staff/views.py
@@ -44,6 +44,7 @@
Semester,
TextAnswer,
UserProfile,
+ VoteTimestamp,
)
from evap.evaluation.tools import (
AttachmentResponse,
@@ -797,6 +798,41 @@ def semester_participation_export(_request, semester_id):
return response
+@manager_required
+def vote_timestamps_export(_request, semester_id):
+ semester = get_object_or_404(Semester, id=semester_id)
+ timestamps = VoteTimestamp.objects.filter(evaluation__course__semester=semester).prefetch_related(
+ "evaluation__course__degrees"
+ )
+
+ filename = f"Voting-Timestamps-{semester.name}.csv"
+ response = AttachmentResponse(filename, content_type="text/csv")
+
+ writer = csv.writer(response, delimiter=";", lineterminator="\n")
+ writer.writerow(
+ [
+ _("Evaluation id"),
+ _("Course type"),
+ _("Course degrees"),
+ _("Vote end date"),
+ _("Timestamp"),
+ ]
+ )
+
+ for timestamp in timestamps:
+ writer.writerow(
+ [
+ timestamp.evaluation.id,
+ timestamp.evaluation.course.type.name,
+ ", ".join([degree.name for degree in timestamp.evaluation.course.degrees.all()]),
+ timestamp.evaluation.vote_end_date,
+ timestamp.timestamp,
+ ]
+ )
+
+ return response
+
+
@manager_required
def semester_questionnaire_assign(request, semester_id):
semester = get_object_or_404(Semester, id=semester_id)
diff --git a/evap/student/tests/test_views.py b/evap/student/tests/test_views.py
index 155aff09ab..e438a3a24f 100644
--- a/evap/student/tests/test_views.py
+++ b/evap/student/tests/test_views.py
@@ -1,3 +1,4 @@
+import datetime
from functools import partial
from django.test.utils import override_settings
@@ -15,6 +16,7 @@
Semester,
TextAnswer,
UserProfile,
+ VoteTimestamp,
)
from evap.evaluation.tests.tools import FuzzyInt, WebTestWith200Check, render_pages
from evap.student.tools import answer_field_id
@@ -351,6 +353,17 @@ def test_answer(self):
).values_list("answer", flat=True)
self.assertEqual(list(answers), ["some bottom text"] * 2)
+ def test_vote_timestamp(self):
+ time_before = datetime.datetime.now()
+ timestamps_before = VoteTimestamp.objects.count()
+ page = self.app.get(self.url, user=self.voting_user1, status=200)
+ form = page.forms["student-vote-form"]
+ self.fill_form(form)
+ form.submit()
+ self.assertEqual(VoteTimestamp.objects.count(), timestamps_before + 1)
+ time = VoteTimestamp.objects.latest("timestamp").timestamp
+ self.assertTrue(time_before < time < datetime.datetime.now())
+
def test_user_cannot_vote_multiple_times(self):
page = self.app.get(self.url, user=self.voting_user1, status=200)
form = page.forms["student-vote-form"]
diff --git a/evap/student/views.py b/evap/student/views.py
index 947453dd60..d2ce342462 100644
--- a/evap/student/views.py
+++ b/evap/student/views.py
@@ -11,7 +11,7 @@
from django.utils.translation import gettext as _
from evap.evaluation.auth import participant_required
-from evap.evaluation.models import NO_ANSWER, Evaluation, RatingAnswerCounter, Semester, TextAnswer
+from evap.evaluation.models import NO_ANSWER, Evaluation, RatingAnswerCounter, Semester, TextAnswer, VoteTimestamp
from evap.results.tools import (
annotate_distributions_and_grades,
get_evaluations_with_course_result_attributes,
@@ -230,6 +230,8 @@ def vote(request, evaluation_id):
contribution=contribution, question=question, answer=textanswer_value
)
+ VoteTimestamp.objects.create(evaluation=evaluation)
+
# Update all answer rows to make sure no system columns give away which one was last modified
# see https://github.com/e-valuation/EvaP/issues/1384
RatingAnswerCounter.objects.filter(contribution__evaluation=evaluation).update(id=F("id"))