diff --git a/evap/development/fixtures/test_data.json b/evap/development/fixtures/test_data.json index b6eb8b250d..94bf7fd5ee 100644 --- a/evap/development/fixtures/test_data.json +++ b/evap/development/fixtures/test_data.json @@ -128389,8 +128389,8 @@ "fields": { "name": "Text Answer Review Reminder", "subject": "[EvaP] Bitte Textantworten überprüfen / Please review text answers", - "plain_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{% for evaluation in evaluations %} - {{ evaluation.full_name_de }}\r\n{% endfor %}\r\n\r\n(Dies ist eine automatisch versendete E-Mail.)\r\n\r\n\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{% for evaluation in evaluations %} - {{ evaluation.full_name_en }}\r\n{% endfor %}\r\n\r\n(This is an automated message.)", - "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.)" + "plain_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{% for evaluation, url in evaluation_url_tuples %} - {{ evaluation.full_name_de }} ({{ url }})\r\n{% endfor %}\r\n\r\n(Dies ist eine automatisch versendete E-Mail.)\r\n\r\n\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{% for evaluation, url in evaluation_url_tuples %} - {{ evaluation.full_name_en }} ({{ url }})\r\n{% endfor %}\r\n\r\n(This is an automated message.)", + "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.)" } }, { diff --git a/evap/evaluation/management/commands/send_reminders.py b/evap/evaluation/management/commands/send_reminders.py index 1fc722fb61..18c6522174 100644 --- a/evap/evaluation/management/commands/send_reminders.py +++ b/evap/evaluation/management/commands/send_reminders.py @@ -1,9 +1,11 @@ import datetime import logging +from typing import List, Tuple from django.conf import settings from django.contrib.auth.models import Group from django.core.management.base import BaseCommand +from django.urls import reverse from evap.evaluation.management.commands.tools import log_exceptions from evap.evaluation.models import EmailTemplate, Evaluation @@ -11,6 +13,25 @@ logger = logging.getLogger(__name__) +def get_sorted_evaluation_url_tuples_with_urgent_review() -> List[Tuple[Evaluation, str]]: + evaluation_url_tuples: List[Tuple[Evaluation, str]] = [ + ( + evaluation, + settings.PAGE_URL + + reverse( + "staff:evaluation_textanswers", + kwargs={"semester_id": evaluation.course.semester.id, "evaluation_id": evaluation.id}, + ), + ) + for evaluation in Evaluation.objects.filter(state=Evaluation.State.EVALUATED) + if evaluation.textanswer_review_state == Evaluation.TextAnswerReviewState.REVIEW_URGENT + ] + evaluation_url_tuples = sorted( + evaluation_url_tuples, key=lambda evaluation_url_tuple: evaluation_url_tuple[0].full_name + ) + return evaluation_url_tuples + + @log_exceptions class Command(BaseCommand): help = "Sends email reminders X days before evaluation ends and reminds managers to review text answers." @@ -49,16 +70,12 @@ def send_student_reminders(): @staticmethod def send_textanswer_reminders(): if datetime.date.today().weekday() in settings.TEXTANSWER_REVIEW_REMINDER_WEEKDAYS: - evaluations = [ - evaluation - for evaluation in Evaluation.objects.filter(state=Evaluation.State.EVALUATED) - if evaluation.textanswer_review_state == Evaluation.TextAnswerReviewState.REVIEW_URGENT - ] - if not evaluations: + evaluation_url_tuples = get_sorted_evaluation_url_tuples_with_urgent_review() + if not evaluation_url_tuples: logger.info("no evaluations require a reminder about text answer review.") return - evaluations = sorted(evaluations, key=lambda evaluation: evaluation.full_name) + for manager in Group.objects.get(name="Manager").user_set.all(): - EmailTemplate.send_textanswer_reminder_to_user(manager, evaluations) + EmailTemplate.send_textanswer_reminder_to_user(manager, evaluation_url_tuples) logger.info("sent text answer review reminders.") diff --git a/evap/evaluation/models.py b/evap/evaluation/models.py index 394017a6c6..fc36bbddaa 100644 --- a/evap/evaluation/models.py +++ b/evap/evaluation/models.py @@ -1995,7 +1995,7 @@ def send_participant_publish_notifications(cls, evaluations, template=None): template.send_to_user(participant, {}, body_params, use_cc=True) @classmethod - def send_textanswer_reminder_to_user(cls, user: UserProfile, evaluations: List[Evaluation]): - body_params = {"user": user, "evaluations": evaluations} + def send_textanswer_reminder_to_user(cls, user: UserProfile, evaluation_url_tuples: List[Tuple[Evaluation, str]]): + 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) diff --git a/evap/evaluation/tests/test_commands.py b/evap/evaluation/tests/test_commands.py index f7e7352e2b..ef97556989 100644 --- a/evap/evaluation/tests/test_commands.py +++ b/evap/evaluation/tests/test_commands.py @@ -348,6 +348,15 @@ def test_send_text_answer_review_reminder(self): management.call_command("send_reminders") self.assertEqual(mock.call_count, 1) + self.assertEqual( + mock.call_args_list[0][0][2].get("evaluation_url_tuples"), + [ + ( + evaluation, + f"{settings.PAGE_URL}/staff/semester/{evaluation.course.semester.id}/evaluation/{evaluation.id}/textanswers", + ) + ], + ) class TestLintCommand(TestCase): diff --git a/evap/locale/de/LC_MESSAGES/django.po b/evap/locale/de/LC_MESSAGES/django.po index c1a72ed93c..11429174ba 100644 --- a/evap/locale/de/LC_MESSAGES/django.po +++ b/evap/locale/de/LC_MESSAGES/django.po @@ -5,8 +5,8 @@ msgid "" msgstr "" "Project-Id-Version: EvaP\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-10-11 14:02+0200\n" -"PO-Revision-Date: 2022-10-11 14:22+0200\n" +"POT-Creation-Date: 2022-12-15 17:43+0100\n" +"PO-Revision-Date: 2022-12-15 17:46+0100\n" "Last-Translator: Johannes Wolf \n" "Language-Team: Johannes Wolf (janno42)\n" "Language: de\n" @@ -14,7 +14,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -"X-Generator: Poedit 3.1.1\n" +"X-Generator: Poedit 3.2.2\n" #: evap/contributor/forms.py:16 msgid "General questionnaires" @@ -34,7 +34,7 @@ msgstr "Name (Deutsch)" msgid "Name (English)" msgstr "Name (Englisch)" -#: evap/contributor/forms.py:73 evap/staff/forms.py:448 +#: evap/contributor/forms.py:73 evap/staff/forms.py:452 msgid "The first day of evaluation must be before the last one." msgstr "Der erste Evaluierungstag muss vor dem letzten liegen." @@ -81,7 +81,7 @@ msgstr "Verantwortliche" #: evap/contributor/templates/contributor_evaluation_form.html:46 #: evap/evaluation/templates/navbar.html:73 evap/results/exporters.py:187 -#: evap/results/templates/results_index.html:26 evap/staff/forms.py:1102 +#: evap/results/templates/results_index.html:26 evap/staff/forms.py:1106 #: evap/staff/templates/staff_degree_index.html:5 #: evap/staff/templates/staff_index.html:48 #: evap/staff/templates/staff_semester_export.html:25 evap/staff/views.py:706 @@ -1378,19 +1378,19 @@ msgid "users" msgstr "Accounts" #: evap/evaluation/models.py:1768 -#: evap/evaluation/templates/contact_modal.html:38 evap/staff/forms.py:654 -#: evap/staff/forms.py:693 +#: evap/evaluation/templates/contact_modal.html:38 evap/staff/forms.py:658 +#: evap/staff/forms.py:697 msgid "Subject" msgstr "Betreff" -#: evap/evaluation/models.py:1769 evap/staff/forms.py:655 -#: evap/staff/forms.py:694 +#: evap/evaluation/models.py:1769 evap/staff/forms.py:659 +#: evap/staff/forms.py:698 #: evap/staff/templates/staff_email_preview_form.html:18 msgid "Plain Text" msgstr "Plain Text" -#: evap/evaluation/models.py:1770 evap/staff/forms.py:656 -#: evap/staff/forms.py:695 +#: evap/evaluation/models.py:1770 evap/staff/forms.py:660 +#: evap/staff/forms.py:699 #: evap/staff/templates/staff_email_preview_form.html:21 msgid "HTML" msgstr "HTML" @@ -1528,7 +1528,7 @@ msgid "Contributors" msgstr "Mitwirkende" #: evap/evaluation/templates/contribution_formset.html:22 -#: evap/evaluation/templates/navbar.html:55 evap/staff/forms.py:573 +#: evap/evaluation/templates/navbar.html:55 evap/staff/forms.py:577 #: evap/staff/templates/staff_index.html:25 #: evap/staff/templates/staff_questionnaire_base.html:6 #: evap/staff/templates/staff_questionnaire_base.html:9 @@ -1776,7 +1776,7 @@ msgid "More" msgstr "Weitere" #: evap/evaluation/templates/navbar.html:72 -#: evap/results/templates/results_index.html:39 evap/staff/forms.py:1107 +#: evap/results/templates/results_index.html:39 evap/staff/forms.py:1111 #: evap/staff/templates/staff_course_type_index.html:5 #: evap/staff/templates/staff_course_type_merge.html:7 #: evap/staff/templates/staff_course_type_merge_selection.html:5 @@ -1886,8 +1886,8 @@ msgstr "Standardwert. Keine weiteren Berechtigungen." #: evap/evaluation/templates/sortable_form_js.html:20 #: evap/grades/templates/grades_course_view.html:33 #: evap/rewards/templates/rewards_reward_point_redemption_event_list.html:27 -#: evap/staff/templates/staff_evaluation_textanswers_quick.html:136 -#: evap/staff/templates/staff_evaluation_textanswers_section.html:52 +#: evap/staff/templates/staff_evaluation_textanswers_quick.html:134 +#: evap/staff/templates/staff_evaluation_textanswers_section.html:51 #: evap/staff/templates/staff_questionnaire_index_list.html:55 #: evap/staff/templates/staff_semester_view.html:24 #: evap/staff/templates/staff_semester_view.html:413 @@ -2294,8 +2294,8 @@ msgstr "Sind Endnoten eingegangen?" #: evap/grades/templates/grades_semester_view.html:99 msgid "" -"Please confirm that the final grades for the course have been submitted but will not be uploaded." +"Please confirm that the final grades for the course have been submitted but will not be uploaded." msgstr "" "Bitte bestätigen Sie, dass die Endnoten für die Veranstaltung eingereicht wurden, aber nicht hochgeladen werden." @@ -2312,8 +2312,8 @@ msgstr "Werden Endnoten hochgeladen?" #: evap/grades/templates/grades_semester_view.html:114 msgid "" -"Please confirm that a grade document for the course will be uploaded later on." +"Please confirm that a grade document for the course will be uploaded later on." msgstr "" "Bitte bestätigen Sie, dass ein Noten-Dokument für die Veranstaltung noch hochgeladen wird." @@ -2503,7 +2503,7 @@ msgstr "Noten" #: evap/results/templates/results_evaluation_detail.html:97 #: evap/results/templates/results_index.html:77 -#: evap/results/templates/results_index.html:96 evap/staff/forms.py:119 +#: evap/results/templates/results_index.html:96 evap/staff/forms.py:123 #: evap/student/templates/student_index.html:5 #: evap/student/templates/student_vote.html:7 msgid "Evaluation" @@ -2615,27 +2615,27 @@ msgstr "Einlösungen" msgid "Number of points" msgstr "Anzahl der Punkte" -#: evap/rewards/models.py:23 +#: evap/rewards/models.py:27 msgid "event name" msgstr "Veranstaltungsname" -#: evap/rewards/models.py:24 +#: evap/rewards/models.py:28 msgid "event date" msgstr "Veranstaltungsdatum" -#: evap/rewards/models.py:25 +#: evap/rewards/models.py:29 msgid "redemption end date" msgstr "Ende des Einlösezeitraums" -#: evap/rewards/models.py:51 +#: evap/rewards/models.py:55 msgid "granting time" msgstr "Vergabezeitpunkt" -#: evap/rewards/models.py:52 evap/rewards/models.py:65 +#: evap/rewards/models.py:56 evap/rewards/models.py:69 msgid "value" msgstr "Wert" -#: evap/rewards/models.py:64 +#: evap/rewards/models.py:68 msgid "redemption time" msgstr "Einlösezeitpunkt" @@ -2653,7 +2653,7 @@ msgstr "" "einlösen kannst." #: evap/rewards/templates/rewards_index.html:16 -#: evap/rewards/templates/rewards_index.html:31 +#: evap/rewards/templates/rewards_index.html:32 msgid "Redeem points" msgstr "Punkte einlösen" @@ -2661,24 +2661,24 @@ msgstr "Punkte einlösen" msgid "Total points available" msgstr "Insgesamt verfügbare Punkte" -#: evap/rewards/templates/rewards_index.html:28 -#: evap/rewards/templates/rewards_index.html:69 +#: evap/rewards/templates/rewards_index.html:29 +#: evap/rewards/templates/rewards_index.html:70 msgid "Date" msgstr "Datum" -#: evap/rewards/templates/rewards_index.html:29 +#: evap/rewards/templates/rewards_index.html:30 msgid "Event" msgstr "Veranstaltung" -#: evap/rewards/templates/rewards_index.html:30 +#: evap/rewards/templates/rewards_index.html:31 msgid "Available until" msgstr "Verfügbar bis" -#: evap/rewards/templates/rewards_index.html:48 +#: evap/rewards/templates/rewards_index.html:49 msgid "Redeem" msgstr "Einlösen" -#: evap/rewards/templates/rewards_index.html:52 +#: evap/rewards/templates/rewards_index.html:53 msgid "" "Currently there are no events available for which you can redeem points. " "We'll send you a message when this changes." @@ -2686,27 +2686,27 @@ msgstr "" "Im Moment gibt es keine Veranstaltungen, für die du Belohnungspunkte " "einlösen kannst. Wir informieren dich, sobald sich das ändert." -#: evap/rewards/templates/rewards_index.html:55 +#: evap/rewards/templates/rewards_index.html:56 msgid "You don't have any reward points that you could redeem." msgstr "Du hast keine Belohnungspunkte, die du einlösen könntest." -#: evap/rewards/templates/rewards_index.html:62 +#: evap/rewards/templates/rewards_index.html:63 msgid "Reward points history" msgstr "Verlauf der Belohnungspunkte" -#: evap/rewards/templates/rewards_index.html:70 +#: evap/rewards/templates/rewards_index.html:71 msgid "Action" msgstr "Aktion" -#: evap/rewards/templates/rewards_index.html:71 +#: evap/rewards/templates/rewards_index.html:72 msgid "Granted points" msgstr "Vergebene Punkte" -#: evap/rewards/templates/rewards_index.html:72 +#: evap/rewards/templates/rewards_index.html:73 msgid "Redeemed points" msgstr "Eingelöste Punkte" -#: evap/rewards/templates/rewards_index.html:87 +#: evap/rewards/templates/rewards_index.html:88 msgid "No reward points earned yet." msgstr "Noch keine Belohnungspunkte verdient." @@ -2724,7 +2724,7 @@ msgid "Save event" msgstr "Veranstaltung speichern" #: evap/rewards/templates/rewards_reward_point_redemption_event_list.html:9 -#: evap/staff/forms.py:474 +#: evap/staff/forms.py:478 msgid "Event date" msgstr "Veranstaltungsdatum" @@ -2776,19 +2776,7 @@ msgstr "" "Soll die Veranstaltung wirklich gelöscht " "werden?" -#: evap/rewards/tools.py:35 -msgid "You cannot redeem 0 points." -msgstr "Du kannst nicht 0 Punkte einlösen." - -#: evap/rewards/tools.py:38 -msgid "You don't have enough reward points." -msgstr "Du hast nicht genügend Belohnungspunkte." - -#: evap/rewards/tools.py:44 -msgid "Sorry, the deadline for this event expired already." -msgstr "Sorry, die Frist für diese Veranstaltung ist bereits abgelaufen." - -#: evap/rewards/tools.py:101 +#: evap/rewards/tools.py:111 #, python-brace-format msgid "{count} reward point was granted on already completed questionnaires." msgid_plural "" @@ -2798,7 +2786,7 @@ msgstr[0] "" msgstr[1] "" "{count} Belohnungspunkte wurden für bereits ausgefüllte Fragebögen vergeben." -#: evap/rewards/tools.py:116 +#: evap/rewards/tools.py:126 #, python-brace-format msgid "You just earned {count} reward point for this semester." msgid_plural "You just earned {count} reward points for this semester." @@ -2807,13 +2795,13 @@ msgstr[0] "" msgstr[1] "" "Du hast soeben {count} Belohnungspunkte für dieses Semester erhalten." -#: evap/rewards/tools.py:122 +#: evap/rewards/tools.py:132 msgid "You now received all possible reward points for this semester. Great!" msgstr "" "Du hast jetzt alle möglichen Belohnungspunkte für dieses Semester erhalten. " "Super!" -#: evap/rewards/tools.py:129 +#: evap/rewards/tools.py:139 msgid "" "We're looking forward to receiving feedback for your other evaluations as " "well." @@ -2823,19 +2811,39 @@ msgstr "Wir freuen uns auch auf dein Feedback in den anderen Evaluierungen." msgid "You successfully redeemed your points." msgstr "Punkte erfolgreich eingelöst." +#: evap/rewards/views.py:47 +msgid "You cannot redeem 0 points." +msgstr "Du kannst nicht 0 Punkte einlösen." + +#: evap/rewards/views.py:49 +msgid "You don't have enough reward points." +msgstr "Du hast nicht genügend Belohnungspunkte." + +#: evap/rewards/views.py:51 +msgid "Sorry, the deadline for this event expired already." +msgstr "Sorry, die Frist für diese Veranstaltung ist bereits abgelaufen." + #: evap/rewards/views.py:55 +msgid "" +"It appears that your browser sent multiple redemption requests. You can see " +"all successful redemptions below." +msgstr "" +"Dein Browser scheint mehrere Anfragen zum Einlösen geschickt zu haben. Du " +"kannst alle erfolgreichen Einlösungen unten sehen." + +#: evap/rewards/views.py:75 msgid "Reward for" msgstr "Belohnung für" -#: evap/rewards/views.py:85 +#: evap/rewards/views.py:106 msgid "Successfully created event." msgstr "Veranstaltung erfolgreich erstellt." -#: evap/rewards/views.py:99 +#: evap/rewards/views.py:120 msgid "Successfully updated event." msgstr "Veranstaltung erfolgreich geändert." -#: evap/rewards/views.py:120 +#: evap/rewards/views.py:141 msgid "RewardPoints" msgstr "Belohnungspunkte" @@ -2858,99 +2866,99 @@ msgstr "Beginn der Evaluierung" msgid "End of evaluation" msgstr "Ende der Evaluierung" -#: evap/staff/forms.py:105 evap/staff/forms.py:114 +#: evap/staff/forms.py:105 evap/staff/forms.py:115 msgid "Excel file" msgstr "Exceldatei" -#: evap/staff/forms.py:141 +#: evap/staff/forms.py:145 msgid "Please select an evaluation from the dropdown menu." msgstr "Bitte wählen Sie eine Evaluierung aus dem Dropdown-Menü aus." -#: evap/staff/forms.py:147 +#: evap/staff/forms.py:151 msgid "User file" msgstr "Account-Datei" -#: evap/staff/forms.py:219 +#: evap/staff/forms.py:223 msgid "You must select two different course types." msgstr "Es müssen zwei unterschiedliche Veranstaltungstypen ausgewählt werden." -#: evap/staff/forms.py:269 +#: evap/staff/forms.py:273 msgid "Start of evaluations" msgstr "Beginn der Evaluierungen" -#: evap/staff/forms.py:270 +#: evap/staff/forms.py:274 msgid "Last day of evaluations" msgstr "Letzter Tag der Evaluierungen" -#: evap/staff/forms.py:353 evap/student/templates/student_vote.html:78 +#: evap/staff/forms.py:357 evap/student/templates/student_vote.html:78 #: evap/student/templates/student_vote.html:121 msgid "General questions" msgstr "Allgemeine Fragen" -#: evap/staff/forms.py:429 +#: evap/staff/forms.py:433 #, python-format msgid "Participants who already voted for the evaluation can't be removed: %s" msgstr "" "Teilnehmende, die bereits für die Evaluierung abgestimmt haben, können nicht " "entfernt werden: %s" -#: evap/staff/forms.py:438 +#: evap/staff/forms.py:442 msgid "" "At least one evaluation of the course must have a weight greater than 0." msgstr "" "Mindestens eine Evaluierung der Veranstaltung muss eine Gewichtung größer 0 " "besitzen." -#: evap/staff/forms.py:475 +#: evap/staff/forms.py:479 msgid "# very good" msgstr "# sehr gut" -#: evap/staff/forms.py:476 +#: evap/staff/forms.py:480 msgid "# good" msgstr "# gut" -#: evap/staff/forms.py:477 +#: evap/staff/forms.py:481 msgid "# neutral" msgstr "# neutral" -#: evap/staff/forms.py:478 +#: evap/staff/forms.py:482 msgid "# bad" msgstr "# schlecht" -#: evap/staff/forms.py:479 +#: evap/staff/forms.py:483 msgid "# very bad" msgstr "# sehr schlecht" -#: evap/staff/forms.py:575 +#: evap/staff/forms.py:579 msgid "Add person without questions" msgstr "Person ohne Fragen hinzufügen" -#: evap/staff/forms.py:618 +#: evap/staff/forms.py:622 msgid "Select either this option or at least one questionnaire!" msgstr "Wählen Sie diese Option oder mindestens einen Fragebogen!" -#: evap/staff/forms.py:652 +#: evap/staff/forms.py:656 msgid "Send email to" msgstr "E‑Mail senden an" -#: evap/staff/forms.py:671 +#: evap/staff/forms.py:675 msgid "No recipient selected. Choose at least one group of recipients." msgstr "" "Keine Empfänger·in ausgewählt. Bitte wählen Sie wenigstens eine Gruppe aus." -#: evap/staff/forms.py:691 +#: evap/staff/forms.py:695 msgid "To" msgstr "An" -#: evap/staff/forms.py:692 +#: evap/staff/forms.py:696 msgid "CC" msgstr "CC" -#: evap/staff/forms.py:764 +#: evap/staff/forms.py:768 msgid "You must have at least one of these." msgstr "Es muss mindestens einen Eintrag geben." -#: evap/staff/forms.py:853 +#: evap/staff/forms.py:857 msgid "" "Please select the name of each added contributor. Remove empty rows if " "necessary." @@ -2958,45 +2966,45 @@ msgstr "" "Bitte wählen Sie die Namen aller hinzugefügten Mitwirkenden aus. Entfernen " "Sie leere Zeilen, falls notwendig." -#: evap/staff/forms.py:857 +#: evap/staff/forms.py:861 msgid "" "Duplicate contributor ({}) found. Each contributor should only be used once." msgstr "" "Doppelte Mitwirkung ({}) gefunden. Alle Mitwirkenden sollten nur einmal " "angegeben werden." -#: evap/staff/forms.py:918 +#: evap/staff/forms.py:922 msgid "All contributors" msgstr "Alle Mitwirkenden" -#: evap/staff/forms.py:923 evap/staff/templates/staff_user_badges.html:3 +#: evap/staff/forms.py:927 evap/staff/templates/staff_user_badges.html:3 msgid "Manager" msgstr "Manager·in" -#: evap/staff/forms.py:924 evap/staff/templates/staff_user_badges.html:8 +#: evap/staff/forms.py:928 evap/staff/templates/staff_user_badges.html:8 msgid "Grade publisher" msgstr "Notenveröffentlicher·in" -#: evap/staff/forms.py:925 evap/staff/templates/staff_user_badges.html:5 +#: evap/staff/forms.py:929 evap/staff/templates/staff_user_badges.html:5 msgid "Reviewer" msgstr "Reviewer" -#: evap/staff/forms.py:926 evap/staff/templates/staff_user_badges.html:23 +#: evap/staff/forms.py:930 evap/staff/templates/staff_user_badges.html:23 msgid "Inactive" msgstr "Inaktiv" -#: evap/staff/forms.py:928 +#: evap/staff/forms.py:932 msgid "Evaluations participating in (active semester)" msgstr "Teilnahme an (aktuelles Semester)" -#: evap/staff/forms.py:966 +#: evap/staff/forms.py:970 #, python-format msgid "Evaluations for which the user already voted can't be removed: %s" msgstr "" "Evaluierungen, für die der Account bereits abgestimmt hat, können nicht " "entfernt werden: %s" -#: evap/staff/forms.py:983 +#: evap/staff/forms.py:987 #, python-format msgid "A user with the email '%s' already exists" msgstr "Ein Account mit der E-Mail-Adresse '%s' existiert bereits" @@ -3049,40 +3057,44 @@ msgstr "Existierende Veranstaltungen" msgid "Ignored duplicates" msgstr "Ignorierte Duplikate" -#: evap/staff/importers/base.py:45 +#: evap/staff/importers/base.py:44 +msgid "Existing participants" +msgstr "Existierende Teilnehmende" + +#: evap/staff/importers/base.py:46 msgid "Degree mismatches" msgstr "Studiengänge stimmen nicht überein" -#: evap/staff/importers/base.py:47 +#: evap/staff/importers/base.py:48 msgid "Unusually high number of enrollments" msgstr "Ungewöhnlich hohe Zahl an Belegungen" -#: evap/staff/importers/base.py:150 +#: evap/staff/importers/base.py:151 msgid "Errors occurred while parsing the input data. No data was imported." msgstr "" "Beim Verarbeiten der Eingabedaten trat ein Fehler auf. Es wurden keine Daten " "importiert." -#: evap/staff/importers/base.py:158 +#: evap/staff/importers/base.py:159 msgid "Import aborted after exception: '{}'. No data was imported." msgstr "" "Importvorgang abgebrochen nach dem Fehler: '{}'. Es wurden keine Daten " "importiert." -#: evap/staff/importers/base.py:171 +#: evap/staff/importers/base.py:172 msgid "Sheet \"{}\", row {}" msgstr "Blatt \"{}\", Zeile {}" -#: evap/staff/importers/base.py:202 +#: evap/staff/importers/base.py:203 msgid "Couldn't read the file. Error: {}" msgstr "Datei konnte nicht gelesen werden. Fehler: {}" -#: evap/staff/importers/base.py:213 +#: evap/staff/importers/base.py:214 msgid "Wrong number of columns in sheet '{}'. Expected: {}, actual: {}" msgstr "" "Falsche Anzahl von Spalten in Blatt '{}'. Erwartet: {}, tatsächlich: {}" -#: evap/staff/importers/base.py:226 +#: evap/staff/importers/base.py:227 #, python-brace-format msgid "" "{location}: Wrong data type. Please make sure all cells are string types, " @@ -3091,23 +3103,23 @@ msgstr "" "{location}: Falscher Datentyp. Bitte alle Zellen als Text und nicht als " "Zahlendaten formatieren." -#: evap/staff/importers/base.py:241 +#: evap/staff/importers/base.py:242 #, python-format msgid "Successfully read sheet '%s'." msgstr "Blatt '%s' erfolgreich gelesen." -#: evap/staff/importers/base.py:243 +#: evap/staff/importers/base.py:244 msgid "Successfully read Excel file." msgstr "Exceldatei erfolgreich gelesen." -#: evap/staff/importers/base.py:267 +#: evap/staff/importers/base.py:268 #, python-brace-format msgid "{location} and {count} other place" msgid_plural "{location} and {count} other places" msgstr[0] "{location} und {count} weiteres Vorkommen" msgstr[1] "{location} und {count} weitere Vorkommen" -#: evap/staff/importers/enrollment.py:276 +#: evap/staff/importers/enrollment.py:280 #, python-brace-format msgid "" "{location}: No course type is associated with the import name " @@ -3116,7 +3128,7 @@ msgstr "" "{location}: Kein Veranstaltungstyp ist mit dem Import-Namen " "\"{course_type}\" verknüpft. Bitte manuell anlegen." -#: evap/staff/importers/enrollment.py:286 +#: evap/staff/importers/enrollment.py:290 #, python-brace-format msgid "" "{location}: \"is_graded\" is {is_graded}, but must be {is_graded_yes} or " @@ -3125,7 +3137,7 @@ msgstr "" "{location}: \"benotet\" ist {is_graded}, aber muss {is_graded_yes} oder " "{is_graded_no} sein" -#: evap/staff/importers/enrollment.py:298 +#: evap/staff/importers/enrollment.py:302 #, python-brace-format msgid "" "{location}: No degree is associated with the import name \"{degree}\". " @@ -3134,26 +3146,26 @@ msgstr "" "{location}: Kein Studiengang ist mit dem Import-Namen \"{degree}\" " "verknüpft. Bitte manuell anlegen." -#: evap/staff/importers/enrollment.py:332 +#: evap/staff/importers/enrollment.py:336 msgid "the course type does not match" msgstr "der Veranstaltungstyp stimmt nicht überein" -#: evap/staff/importers/enrollment.py:336 +#: evap/staff/importers/enrollment.py:340 msgid "the responsibles of the course do not match" msgstr "die Verantwortlichen der Veranstaltung stimmen nicht überein" -#: evap/staff/importers/enrollment.py:339 +#: evap/staff/importers/enrollment.py:343 msgid "the existing course does not have exactly one evaluation" msgstr "die bestehende Veranstaltung hat nicht genau eine Evaluierung" -#: evap/staff/importers/enrollment.py:341 +#: evap/staff/importers/enrollment.py:345 msgid "" "the evaluation of the existing course has a mismatching grading specification" msgstr "" "die Evaluierung der bestehenden Veranstaltung unterscheidet sich in der " "Angabe zur Benotung" -#: evap/staff/importers/enrollment.py:423 +#: evap/staff/importers/enrollment.py:427 #, python-brace-format msgid "" "Course \"{course_name}\" already exists. The course will not be created, " @@ -3165,7 +3177,7 @@ msgstr "" "der bestehenden Veranstaltung importiert und alle zusätzlichen Studiengänge " "werden hinzugefügt." -#: evap/staff/importers/enrollment.py:434 +#: evap/staff/importers/enrollment.py:438 #, python-brace-format msgid "" "{location}: Course {course_name} already exists in this semester, but the " @@ -3175,7 +3187,7 @@ msgstr "" "bereits, aber die Veranstaltungen können aus diesen Gründen nicht " "zusammengeführt werden:{reasons}" -#: evap/staff/importers/enrollment.py:446 +#: evap/staff/importers/enrollment.py:450 #, python-brace-format msgid "" "{location}: Course \"{course_name}\" (DE) already exists in this semester " @@ -3184,7 +3196,7 @@ msgstr "" "{location}: Veranstaltung \"{course_name}\" (DE) existiert bereits in diesem " "Semester mit anderem englischen Namen." -#: evap/staff/importers/enrollment.py:457 +#: evap/staff/importers/enrollment.py:461 #, python-brace-format msgid "" "{location}: Course \"{course_name}\" (EN) already exists in this semester " @@ -3193,7 +3205,7 @@ msgstr "" "{location}: Veranstaltung \"{course_name}\" (EN) existiert bereits in diesem " "Semester mit anderem deutschen Namen." -#: evap/staff/importers/enrollment.py:468 +#: evap/staff/importers/enrollment.py:472 #, python-brace-format msgid "" "{location}: The German name for course \"{course_name}\" is already used for " @@ -3202,25 +3214,34 @@ msgstr "" "{location}: Der deutsche Name der Veranstaltung \"{course_name}\" existiert " "bereits für eine andere Veranstaltung in der Importdatei." -#: evap/staff/importers/enrollment.py:499 +#: evap/staff/importers/enrollment.py:517 #, python-brace-format msgid "" -"{location}: The data of course \"{name}\" differs from its data in a " -"previous row." +"Course {course_name}: {participant_count} participants from the import file " +"already participate in the evaluation." +msgstr "" +"Veranstaltung {course_name}: {participant_count} Teilnehmende aus der Import-" +"Datei sind bereits für die Evaluierung eingetragen." + +#: evap/staff/importers/enrollment.py:547 +#, python-brace-format +msgid "" +"{location}: The data of course \"{name}\" differs from its data in the " +"columns ({columns}) in a previous row." msgstr "" -"{location}: Die Daten der Veranstaltung \"{name}\" unterscheiden sich von " -"den Daten in einer vorherigen Zeile." +"{location}: Die Daten der Veranstaltung \"{name}\" unterscheiden sich in den " +"Spalten ({columns}) von den Daten in einer vorherigen Zeile." -#: evap/staff/importers/enrollment.py:525 +#: evap/staff/importers/enrollment.py:575 msgid "Warning: User {} has {} enrollments, which is a lot." msgstr "Warnung: Account {} hat ungewöhnlich viele Belegungen ({})." -#: evap/staff/importers/enrollment.py:595 evap/staff/importers/user.py:346 +#: evap/staff/importers/enrollment.py:646 evap/staff/importers/user.py:346 msgid "The test run showed no errors. No data was imported yet." msgstr "" "Der Testlauf ergab keine Fehler. Es wurden noch keine Daten importiert." -#: evap/staff/importers/enrollment.py:597 +#: evap/staff/importers/enrollment.py:648 #, python-brace-format msgid "" "The import run will create {evaluation_count} courses/evaluations and " @@ -3229,7 +3250,7 @@ msgstr "" "Der Import wird {evaluation_count} Veranstaltungen/Evaluierungen und " "{user_count} Accounts anlegen:{users}" -#: evap/staff/importers/enrollment.py:611 +#: evap/staff/importers/enrollment.py:662 msgid "" "Successfully created {} courses/evaluations, {} participants and {} " "contributors:{}" @@ -3237,7 +3258,7 @@ msgstr "" "Erfolgreich {} Veranstaltungen/Evaluierungen, {} Teilnehmende und {} " "Mitwirkende erstellt:{}" -#: evap/staff/importers/enrollment.py:646 +#: evap/staff/importers/enrollment.py:697 #, python-brace-format msgid "" "{location}: The degree of course \"{course_name}\" differs from its degrees " @@ -3739,7 +3760,7 @@ msgstr "" #: evap/staff/templates/staff_evaluation_textanswer_edit.html:5 #: evap/staff/templates/staff_evaluation_textanswers.html:7 -#: evap/staff/templates/staff_evaluation_textanswers_quick.html:41 +#: evap/staff/templates/staff_evaluation_textanswers_quick.html:39 msgid "Text answers" msgstr "Textantworten" @@ -3757,7 +3778,7 @@ msgid "Quick" msgstr "Schnell" #: evap/staff/templates/staff_evaluation_textanswers.html:25 -#: evap/staff/templates/staff_evaluation_textanswers_section.html:49 +#: evap/staff/templates/staff_evaluation_textanswers_section.html:48 msgid "Undecided" msgstr "Offen" @@ -3820,59 +3841,59 @@ msgid "Edit answer" msgstr "Antwort bearbeiten" #: evap/staff/templates/staff_evaluation_textanswers_quick.html:22 -#: evap/staff/templates/staff_evaluation_textanswers_quick.html:143 +#: evap/staff/templates/staff_evaluation_textanswers_quick.html:141 msgid "Review next evaluation" msgstr "Nächste Evaluierung überprüfen" #: evap/staff/templates/staff_evaluation_textanswers_quick.html:23 -#: evap/staff/templates/staff_evaluation_textanswers_quick.html:147 +#: evap/staff/templates/staff_evaluation_textanswers_quick.html:145 msgid "Skip evaluation" msgstr "Evaluierung überspringen" #: evap/staff/templates/staff_evaluation_textanswers_quick.html:24 -#: evap/staff/templates/staff_evaluation_textanswers_quick.html:151 +#: evap/staff/templates/staff_evaluation_textanswers_quick.html:149 msgid "Show all again" msgstr "Alle noch einmal anzeigen" #: evap/staff/templates/staff_evaluation_textanswers_quick.html:25 -#: evap/staff/templates/staff_evaluation_textanswers_quick.html:154 +#: evap/staff/templates/staff_evaluation_textanswers_quick.html:152 msgid "Show undecided" msgstr "Offene anzeigen" -#: evap/staff/templates/staff_evaluation_textanswers_quick.html:42 +#: evap/staff/templates/staff_evaluation_textanswers_quick.html:40 msgid "Hotkeys" msgstr "Hotkeys" -#: evap/staff/templates/staff_evaluation_textanswers_quick.html:48 -#: evap/staff/templates/staff_evaluation_textanswers_quick.html:112 +#: evap/staff/templates/staff_evaluation_textanswers_quick.html:46 +#: evap/staff/templates/staff_evaluation_textanswers_quick.html:110 #: evap/staff/templates/staff_semester_view.html:86 msgid "Reviewed text answers" msgstr "Überprüfte Textantworten" -#: evap/staff/templates/staff_evaluation_textanswers_quick.html:50 -#: evap/staff/templates/staff_evaluation_textanswers_quick.html:114 +#: evap/staff/templates/staff_evaluation_textanswers_quick.html:48 +#: evap/staff/templates/staff_evaluation_textanswers_quick.html:112 msgid "Unreviewed text answers" msgstr "Nicht überprüfte Textantworten" -#: evap/staff/templates/staff_evaluation_textanswers_quick.html:55 +#: evap/staff/templates/staff_evaluation_textanswers_quick.html:53 #: evap/staff/templates/staff_evaluation_textanswers_section.html:6 msgid "responsible" msgstr "verantwortlich" -#: evap/staff/templates/staff_evaluation_textanswers_quick.html:94 +#: evap/staff/templates/staff_evaluation_textanswers_quick.html:92 msgid "Some text answers for this evaluation are still unreviewed." msgstr "" "Manche Textantworten für diese Evaluierung wurden noch nicht überprüft." -#: evap/staff/templates/staff_evaluation_textanswers_quick.html:97 +#: evap/staff/templates/staff_evaluation_textanswers_quick.html:95 msgid "You have reviewed all text answers for this evaluation." msgstr "Du hast alle Textantworten für diese Evaluierung überprüft." -#: evap/staff/templates/staff_evaluation_textanswers_quick.html:101 +#: evap/staff/templates/staff_evaluation_textanswers_quick.html:99 msgid "There are no text answers for this evaluation." msgstr "Es gibt keine Textantworten für diese Evaluierung." -#: evap/staff/templates/staff_evaluation_textanswers_quick.html:106 +#: evap/staff/templates/staff_evaluation_textanswers_quick.html:104 #, python-format msgid "" "The next evaluation \"%(name)s\" has got %(answers)s unreviewed text answer." @@ -3885,24 +3906,24 @@ msgstr[1] "" "Die nächste Evaluierung \"%(name)s\" hat %(answers)s noch nicht überprüfte " "Textantworten." -#: evap/staff/templates/staff_evaluation_textanswers_quick.html:123 +#: evap/staff/templates/staff_evaluation_textanswers_quick.html:121 msgid "Unreview" msgstr "Entscheidung zurücknehmen" -#: evap/staff/templates/staff_evaluation_textanswers_quick.html:129 -#: evap/staff/templates/staff_evaluation_textanswers_section.html:43 +#: evap/staff/templates/staff_evaluation_textanswers_quick.html:127 +#: evap/staff/templates/staff_evaluation_textanswers_section.html:42 #: evap/staff/templates/staff_semester_view.html:328 msgid "Publish" msgstr "Veröffentlichen" -#: evap/staff/templates/staff_evaluation_textanswers_quick.html:132 +#: evap/staff/templates/staff_evaluation_textanswers_quick.html:130 msgid "This answer is for a general question and can't be made private." msgstr "" "Diese Antwort gehört zu einer allgemeinen Frage und kann nicht als privat " "markiert werden." -#: evap/staff/templates/staff_evaluation_textanswers_quick.html:133 -#: evap/staff/templates/staff_evaluation_textanswers_section.html:46 +#: evap/staff/templates/staff_evaluation_textanswers_quick.html:131 +#: evap/staff/templates/staff_evaluation_textanswers_section.html:45 msgid "Private" msgstr "Privat" @@ -4519,10 +4540,10 @@ msgstr "" #: evap/staff/templates/staff_semester_view.html:503 msgid "" -"Do you really want to archive the results in the semester ? This will make the results of all evaluations inaccessible " -"for all users except their contributors and managers. You can't undo this " -"action." +"Do you really want to archive the results in the semester ? This will make the results of all evaluations " +"inaccessible for all users except their contributors and managers. You can't " +"undo this action." msgstr "" "Möchtest du die Ergebnisse im Semester " "wirklich archivieren? Die Ergebnisse aller Evaluierungen sind dann nur noch " @@ -4717,10 +4738,10 @@ msgstr "Massen-Account-Aktualisierung" #: evap/staff/templates/staff_user_bulk_update.html:17 msgid "" -"Upload a text file containing one user per line formatted as \"username,email" -"\". Users in this file will be updated or created. All users that are not in " -"this file will be deleted if possible. If they can't be deleted they will be " -"marked inactive." +"Upload a text file containing one user per line formatted as \"username," +"email\". Users in this file will be updated or created. All users that are " +"not in this file will be deleted if possible. If they can't be deleted they " +"will be marked inactive." msgstr "" "Lade eine Textdatei hoch, die einen Account pro Zeile im Format " "\"accountname,email\" enthält. Accounts in dieser Datei werden aktualisiert " @@ -5192,42 +5213,42 @@ msgstr "{} Mitwirkende wurden aus der Evaluierung {} entfernt" msgid "Login key" msgstr "Anmeldeschlüssel" -#: evap/staff/views.py:1657 evap/staff/views.py:1759 evap/staff/views.py:1804 +#: evap/staff/views.py:1658 evap/staff/views.py:1760 evap/staff/views.py:1805 msgid "Successfully created questionnaire." msgstr "Fragebogen erfolgreich erstellt." -#: evap/staff/views.py:1723 +#: evap/staff/views.py:1724 msgid "Successfully updated questionnaire." msgstr "Fragebogen erfolgreich geändert." -#: evap/staff/views.py:1779 +#: evap/staff/views.py:1780 msgid "" "Questionnaire creation aborted. A new version was already created today." msgstr "" "Fragebogen-Erstellung abgebrochen. Es wurde heute bereits eine neue Version " "angelegt." -#: evap/staff/views.py:1886 +#: evap/staff/views.py:1887 msgid "Successfully updated the degrees." msgstr "Studiengänge erfolgreich geändert." -#: evap/staff/views.py:1903 +#: evap/staff/views.py:1904 msgid "Successfully updated the course types." msgstr "Veranstaltungstypen erfolgreich geändert." -#: evap/staff/views.py:1931 +#: evap/staff/views.py:1932 msgid "Successfully merged course types." msgstr "Veranstaltungstypen erfolgreich zusammengeführt." -#: evap/staff/views.py:1953 +#: evap/staff/views.py:1954 msgid "Successfully updated text warning answers." msgstr "Textantwort-Warnungen erfolgreich geändert." -#: evap/staff/views.py:2020 +#: evap/staff/views.py:2021 msgid "Successfully created user." msgstr "Account erfolgreich erstellt." -#: evap/staff/views.py:2078 +#: evap/staff/views.py:2079 #, python-brace-format msgid "" "The removal of evaluations has granted the user \"{granting.user_profile." @@ -5244,15 +5265,15 @@ msgstr[1] "" "user_profile.email}\" {granting.value} Belohnungspunkte für das aktive " "Semester vergeben." -#: evap/staff/views.py:2096 +#: evap/staff/views.py:2097 msgid "Successfully updated user." msgstr "Account erfolgreich geändert." -#: evap/staff/views.py:2114 +#: evap/staff/views.py:2115 msgid "Successfully deleted user." msgstr "Account erfolgreich gelöscht." -#: evap/staff/views.py:2143 +#: evap/staff/views.py:2144 msgid "" "An error happened when processing the file. Make sure the file meets the " "requirements." @@ -5260,25 +5281,25 @@ msgstr "" "Ein Fehler ist bei der Verarbeitung der Datei aufgetreten. Stelle sicher, " "dass die Datei allen Anforderungen genügt." -#: evap/staff/views.py:2178 +#: evap/staff/views.py:2179 msgid "Merging the users failed. No data was changed." msgstr "" "Das Zusammenführen der Accounts ist fehlgeschlagen. Es wurden keine Daten " "verändert." -#: evap/staff/views.py:2180 +#: evap/staff/views.py:2181 msgid "Successfully merged users." msgstr "Accounts erfolgreich zusammengeführt." -#: evap/staff/views.py:2198 +#: evap/staff/views.py:2199 msgid "Successfully updated template." msgstr "Vorlage erfolgreich geändert." -#: evap/staff/views.py:2240 +#: evap/staff/views.py:2242 msgid "Successfully updated the FAQ sections." msgstr "FAQ-Abschnitte erfolgreich geändert." -#: evap/staff/views.py:2258 +#: evap/staff/views.py:2260 msgid "Successfully updated the FAQ questions." msgstr "FAQ-Fragen erfolgreich geändert." diff --git a/evap/rewards/models.py b/evap/rewards/models.py index c8d821e357..065dc4abcf 100644 --- a/evap/rewards/models.py +++ b/evap/rewards/models.py @@ -15,6 +15,10 @@ class NotEnoughPoints(Exception): """An attempt has been made to redeem more points than available.""" +class OutdatedRedemptionData(Exception): + """A redemption request has been sent with outdated data, e.g. when a request has been sent twice.""" + + class RedemptionEventExpired(Exception): """An attempt has been made to redeem more points for an event whose redeem_end_date lies in the past.""" diff --git a/evap/rewards/templates/rewards_index.html b/evap/rewards/templates/rewards_index.html index 5ef4c06d35..4ad138271b 100644 --- a/evap/rewards/templates/rewards_index.html +++ b/evap/rewards/templates/rewards_index.html @@ -22,6 +22,7 @@
{% csrf_token %} + diff --git a/evap/rewards/tests/test_views.py b/evap/rewards/tests/test_views.py index 961988cadf..2645446852 100644 --- a/evap/rewards/tests/test_views.py +++ b/evap/rewards/tests/test_views.py @@ -13,7 +13,7 @@ RewardPointRedemptionEvent, SemesterActivation, ) -from evap.rewards.tools import is_semester_activated, reward_points_of_user +from evap.rewards.tools import is_semester_activated, redeemed_points_of_user, reward_points_of_user from evap.staff.tests.utils import WebTestStaffMode, WebTestStaffModeWith200Check @@ -65,8 +65,16 @@ def test_redeem_too_many_points(self): form = response.forms["reward-redemption-form"] form.set(f"points-{self.event1.pk}", 3) form.set(f"points-{self.event2.pk}", 3) - response = form.submit() - self.assertContains(response, "have enough reward points.") + response = form.submit(status=400) + self.assertContains(response, "have enough reward points.", status_code=400) + self.assertEqual(5, reward_points_of_user(self.student)) + + def test_redeem_zero_points(self): + response = self.app.get(self.url, user=self.student) + form = response.forms["reward-redemption-form"] + form.set(f"points-{self.event1.pk}", 0) + response = form.submit(status=400) + self.assertContains(response, "cannot redeem 0 points.", status_code=400) self.assertEqual(5, reward_points_of_user(self.student)) def test_redeem_points_for_expired_event(self): @@ -75,17 +83,50 @@ def test_redeem_points_for_expired_event(self): form = response.forms["reward-redemption-form"] form.set(f"points-{self.event2.pk}", 1) RewardPointRedemptionEvent.objects.update(redeem_end_date=date.today() - timedelta(days=1)) - response = form.submit() - self.assertContains(response, "event expired already.") + response = form.submit(status=400) + self.assertContains(response, "event expired already.", status_code=400) self.assertEqual(5, reward_points_of_user(self.student)) + def post_redemption_request(self, redemption_params, additional_params=None, status=200): + if additional_params is None: + additional_params = { + "previous_redeemed_points": redeemed_points_of_user(self.student), + } + return self.app.post( + self.url, params={**redemption_params, **additional_params}, user=self.student, status=status + ) + def test_invalid_post_parameters(self): - self.app.post(self.url, params={"points-asd": 2}, user=self.student, status=400) - self.app.post(self.url, params={"points-": 2}, user=self.student, status=400) - self.app.post(self.url, params={f"points-{self.event1.pk}": ""}, user=self.student, status=400) - self.app.post(self.url, params={f"points-{self.event1.pk}": "asd"}, user=self.student, status=400) + self.post_redemption_request({"points-asd": 2}, status=400) + self.post_redemption_request({"points-": 2}, status=400) + self.post_redemption_request({f"points-{self.event1.pk}": ""}, status=400) + self.post_redemption_request({f"points-{self.event1.pk}": "asd"}, status=400) + + # redemption without or with invalid point parameters + self.post_redemption_request( + redemption_params={f"points-{self.event1.pk}": 1}, additional_params={}, status=400 + ) + self.post_redemption_request( + redemption_params={f"points-{self.event1.pk}": 1}, + additional_params={"previous_redeemed_points": "asd"}, + status=400, + ) self.assertFalse(RewardPointRedemption.objects.filter(user_profile=self.student).exists()) + # now, a correct request succeeds + self.post_redemption_request({f"points-{self.event1.pk}": 2}) + + def test_inconsistent_previous_redemption_counts(self): + response1 = self.app.get(self.url, user=self.student) + form1 = response1.forms["reward-redemption-form"] + form1.set(f"points-{self.event1.pk}", 2) + response2 = self.app.get(self.url, user=self.student) + form2 = response2.forms["reward-redemption-form"] + form2.set(f"points-{self.event1.pk}", 2) + form1.submit() + form2.submit(status=409) + self.assertEqual(1, RewardPointRedemption.objects.filter(user_profile=self.student).count()) + class TestEventsView(WebTestStaffModeWith200Check): url = reverse("rewards:reward_point_redemption_events") diff --git a/evap/rewards/tools.py b/evap/rewards/tools.py index 02840b7385..e616062c92 100644 --- a/evap/rewards/tools.py +++ b/evap/rewards/tools.py @@ -14,6 +14,7 @@ from evap.rewards.models import ( NoPointsSelected, NotEnoughPoints, + OutdatedRedemptionData, RedemptionEventExpired, RewardPointGranting, RewardPointRedemption, @@ -23,25 +24,30 @@ @transaction.atomic -def save_redemptions(request, redemptions: Dict[int, int]): +def save_redemptions(request, redemptions: Dict[int, int], previous_redeemed_points: int): # lock these rows to prevent race conditions list(request.user.reward_point_grantings.select_for_update()) list(request.user.reward_point_redemptions.select_for_update()) + # check consistent previous redeemed points + # do not validate reward points, to allow receiving points after page load + if previous_redeemed_points != redeemed_points_of_user(request.user): + raise OutdatedRedemptionData() + total_points_available = reward_points_of_user(request.user) total_points_redeemed = sum(redemptions.values()) if total_points_redeemed <= 0: - raise NoPointsSelected(_("You cannot redeem 0 points.")) + raise NoPointsSelected() if total_points_redeemed > total_points_available: - raise NotEnoughPoints(_("You don't have enough reward points.")) + raise NotEnoughPoints() for event_id in redemptions: if redemptions[event_id] > 0: event = get_object_or_404(RewardPointRedemptionEvent, pk=event_id) if event.redeem_end_date < date.today(): - raise RedemptionEventExpired(_("Sorry, the deadline for this event expired already.")) + raise RedemptionEventExpired() RewardPointRedemption.objects.create(user_profile=request.user, value=redemptions[event_id], event=event) @@ -60,6 +66,10 @@ def reward_points_of_user(user): return count +def redeemed_points_of_user(user): + return RewardPointRedemption.objects.filter(user_profile=user).aggregate(Sum("value"))["value__sum"] or 0 + + def is_semester_activated(semester): return SemesterActivation.objects.filter(semester=semester, is_active=True).exists() diff --git a/evap/rewards/views.py b/evap/rewards/views.py index f6b55636d6..e614edca42 100644 --- a/evap/rewards/views.py +++ b/evap/rewards/views.py @@ -16,6 +16,7 @@ from evap.rewards.models import ( NoPointsSelected, NotEnoughPoints, + OutdatedRedemptionData, RedemptionEventExpired, RewardPointGranting, RewardPointRedemption, @@ -26,24 +27,43 @@ from evap.staff.views import semester_view +def redeem_reward_points(request): + redemptions = {} + try: + for key, value in request.POST.items(): + if key.startswith("points-"): + event_id = int(key.rpartition("-")[2]) + redemptions[event_id] = int(value) + previous_redeemed_points = int(request.POST["previous_redeemed_points"]) + except (ValueError, KeyError, TypeError) as e: + raise BadRequest from e + + try: + save_redemptions(request, redemptions, previous_redeemed_points) + messages.success(request, _("You successfully redeemed your points.")) + except (NoPointsSelected, NotEnoughPoints, RedemptionEventExpired, OutdatedRedemptionData) as error: + status_code = 400 + if isinstance(error, NoPointsSelected): + error_string = _("You cannot redeem 0 points.") + elif isinstance(error, NotEnoughPoints): + error_string = _("You don't have enough reward points.") + elif isinstance(error, RedemptionEventExpired): + error_string = _("Sorry, the deadline for this event expired already.") + elif isinstance(error, OutdatedRedemptionData): + status_code = 409 + error_string = _( + "It appears that your browser sent multiple redemption requests. You can see all successful redemptions below." + ) + messages.error(request, error_string) + return status_code + return 200 + + @reward_user_required def index(request): + status = 200 if request.method == "POST": - redemptions = {} - try: - for key, value in request.POST.items(): - if key.startswith("points-"): - event_id = int(key.rpartition("-")[2]) - redemptions[event_id] = int(value) - except ValueError as e: - raise BadRequest from e - - try: - save_redemptions(request, redemptions) - messages.success(request, _("You successfully redeemed your points.")) - except (NoPointsSelected, NotEnoughPoints, RedemptionEventExpired) as error: - messages.warning(request, error) - + status = redeem_reward_points(request) total_points_available = reward_points_of_user(request.user) reward_point_grantings = RewardPointGranting.objects.filter(user_profile=request.user) reward_point_redemptions = RewardPointRedemption.objects.filter(user_profile=request.user) @@ -62,9 +82,10 @@ def index(request): template_data = dict( reward_point_actions=reward_point_actions, total_points_available=total_points_available, + total_points_spent=sum(redemption.value for redemption in reward_point_redemptions), events=events, ) - return render(request, "rewards_index.html", template_data) + return render(request, "rewards_index.html", template_data, status=status) @manager_required diff --git a/evap/staff/forms.py b/evap/staff/forms.py index d7f8003fb5..573f140535 100644 --- a/evap/staff/forms.py +++ b/evap/staff/forms.py @@ -111,7 +111,11 @@ class ImportForm(forms.Form): class UserImportForm(forms.Form): use_required_attribute = False - excel_file = forms.FileField(label=_("Excel file"), required=False) + excel_file = forms.FileField( + label=_("Excel file"), + required=False, + widget=forms.FileInput(attrs={"accept": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}), + ) class EvaluationParticipantCopyForm(forms.Form): diff --git a/evap/staff/importers/base.py b/evap/staff/importers/base.py index 771a778ee7..e7decfbf63 100644 --- a/evap/staff/importers/base.py +++ b/evap/staff/importers/base.py @@ -41,10 +41,11 @@ class Category(Enum): DUPL = _CATEGORY_TUPLE("duplicate", gettext_lazy("Possible duplicates"), 9) EXISTS = _CATEGORY_TUPLE("existing", gettext_lazy("Existing courses"), 10) IGNORED = _CATEGORY_TUPLE("ignored", gettext_lazy("Ignored duplicates"), 11) + ALREADY_PARTICIPATING = _CATEGORY_TUPLE("already_participating", gettext_lazy("Existing participants"), 12) - DEGREE = _CATEGORY_TUPLE("degree", gettext_lazy("Degree mismatches"), 12) + DEGREE = _CATEGORY_TUPLE("degree", gettext_lazy("Degree mismatches"), 13) TOO_MANY_ENROLLMENTS = _CATEGORY_TUPLE( - "too_many_enrollments", gettext_lazy("Unusually high number of enrollments"), 13 + "too_many_enrollments", gettext_lazy("Unusually high number of enrollments"), 14 ) level: Level diff --git a/evap/staff/importers/enrollment.py b/evap/staff/importers/enrollment.py index f06c5468ed..e368263900 100644 --- a/evap/staff/importers/enrollment.py +++ b/evap/staff/importers/enrollment.py @@ -10,7 +10,7 @@ from typing_extensions import TypeGuard from evap.evaluation.models import Contribution, Course, CourseType, Degree, Evaluation, Semester, UserProfile -from evap.evaluation.tools import ilen +from evap.evaluation.tools import clean_email, ilen, unordered_groupby from evap.staff.tools import create_user_list_html_string_for_message from .base import ( @@ -34,7 +34,9 @@ ) +@dataclass class InvalidValue: + # We make this a dataclass to make sure all instances compare equal. pass @@ -58,11 +60,13 @@ class CourseData: # An existing course that this imported one should be merged with. See #1596 merge_into_course: MaybeInvalid[Optional[Course]] = invalid_value - def equals_when_ignoring_degrees(self, other) -> bool: - def key(data): - return (data.name_de, data.name_en, data.course_type, data.is_graded, data.responsible_email) + def __post_init__(self): + self.name_de = self.name_de.strip() + self.name_en = self.name_en.strip() + self.responsible_email = clean_email(self.responsible_email) - return key(self) == key(other) + def differing_fields(self, other) -> Set[str]: + return {field.name for field in fields(self) if getattr(self, field.name) != getattr(other, field.name)} class ValidCourseData(CourseData): @@ -250,12 +254,12 @@ def _map_row(self, row: EnrollmentInputRow) -> EnrollmentParsedRow: self.invalid_is_graded_tracker.add_location_for_key(row.location, e.invalid_is_graded) course_data = CourseData( - name_de=row.evaluation_name_de.strip(), - name_en=row.evaluation_name_en.strip(), + name_de=row.evaluation_name_de, + name_en=row.evaluation_name_en, degrees=degrees, course_type=course_type, is_graded=is_graded, - responsible_email=row.responsible_email.strip(), + responsible_email=row.responsible_email, ) return EnrollmentParsedRow( @@ -326,7 +330,7 @@ def __init__(self, semester: Semester): self.courses_by_name_en = {course.name_en: course for course in courses} @staticmethod - def get_merge_hindrances(course_data: CourseData, merge_candidate: Course): + def get_merge_hindrances(course_data: CourseData, merge_candidate: Course) -> List[str]: hindrances = [] if merge_candidate.type != course_data.course_type: hindrances.append(_("the course type does not match")) @@ -342,7 +346,7 @@ def get_merge_hindrances(course_data: CourseData, merge_candidate: Course): return hindrances - def set_course_merge_target(self, course_data: CourseData): + def set_course_merge_target(self, course_data: CourseData) -> None: course_with_same_name_en = self.courses_by_name_en.get(course_data.name_en, None) course_with_same_name_de = self.courses_by_name_de.get(course_data.name_de, None) @@ -393,7 +397,7 @@ def __init__(self, *args, semester: Semester, **kwargs): self.name_en_by_name_de: Dict[str, str] = {} self.name_de_mismatch_tracker: FirstLocationAndCountTracker = FirstLocationAndCountTracker() - def check_course_data(self, course_data: CourseData, location: ExcelFileLocation): + def check_course_data(self, course_data: CourseData, location: ExcelFileLocation) -> None: try: self.course_merge_logic.set_course_merge_target(course_data) @@ -474,6 +478,48 @@ def finalize(self) -> None: ) +class ExistingParticipationChecker(Checker, RowCheckerMixin): + """Warn if users are already stored as participants for a course in the database""" + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.participant_emails_per_course_name_en: DefaultDict[str, Set[str]] = defaultdict(set) + + def check_row(self, row: EnrollmentParsedRow) -> None: + # invalid value will still be set for courses with merge conflicts + if row.course_data.merge_into_course is not None and row.course_data.merge_into_course != invalid_value: + self.participant_emails_per_course_name_en[row.course_data.name_en].add(row.student_data.email) + + def finalize(self) -> None: + # To reduce database traffic, we only load existing participations for users and evaluations seen in the import + # file. They could still contain false positives, so we need to check each import row against these tuples + seen_user_emails = [ + email for email_set in self.participant_emails_per_course_name_en.values() for email in email_set + ] + seen_evaluation_names = self.participant_emails_per_course_name_en.keys() + + existing_participation_pairs = [ + (participation.evaluation.course.name_en, participation.userprofile.email) + for participation in Evaluation.participants.through.objects.filter( + evaluation__course__name_en__in=seen_evaluation_names, userprofile__email__in=seen_user_emails + ).prefetch_related("userprofile", "evaluation__course") + ] + + existing_participant_emails_per_course_name_en = unordered_groupby(existing_participation_pairs) + + for course_name_en, import_participant_emails in self.participant_emails_per_course_name_en.items(): + existing_participant_emails = set(existing_participant_emails_per_course_name_en.get(course_name_en, [])) + colliding_participant_emails = existing_participant_emails.intersection(import_participant_emails) + + if colliding_participant_emails: + self.importer_log.add_warning( + _( + "Course {course_name}: {participant_count} participants from the import file already participate in the evaluation." + ).format(course_name=course_name_en, participant_count=len(colliding_participant_emails)), + category=ImporterLogEntry.Category.ALREADY_PARTICIPATING, + ) + + class CourseDataMismatchChecker(Checker): """Assert CourseData is consistent between rows""" @@ -483,22 +529,26 @@ def __init__(self, *args, **kwargs) -> None: self.course_data_by_name_en: Dict[str, CourseData] = {} self.tracker: FirstLocationAndCountTracker = FirstLocationAndCountTracker() - def check_course_data(self, course_data: CourseData, location: ExcelFileLocation): + def check_course_data(self, course_data: CourseData, location: ExcelFileLocation) -> None: if not all_fields_valid(course_data): return stored = self.course_data_by_name_en.setdefault(course_data.name_en, course_data) # degrees would be merged if course data is equal otherwise. - if not course_data.equals_when_ignoring_degrees(stored): - self.tracker.add_location_for_key(location, course_data.name_en) + differing_fields = course_data.differing_fields(stored) - {"degrees"} + if differing_fields: + self.tracker.add_location_for_key(location, (course_data.name_en, tuple(differing_fields))) def finalize(self) -> None: - for key, location_string in self.tracker.aggregated_keys_and_location_strings(): + for (course_name, differing_fields), location_string in self.tracker.aggregated_keys_and_location_strings(): self.importer_log.add_error( - _('{location}: The data of course "{name}" differs from its data in a previous row.').format( + _( + '{location}: The data of course "{name}" differs from its data in the columns ({columns}) in a previous row.' + ).format( location=location_string, - name=key, + name=course_name, + columns=", ".join(differing_fields), ), category=ImporterLogEntry.Category.COURSE, ) @@ -577,6 +627,7 @@ def import_enrollments( UserDataAdapter(UserDataEmptyFieldsChecker(test_run, importer_log)), UserDataAdapter(UserDataMismatchChecker(test_run, importer_log)), UserDataAdapter(UserDataValidationChecker(test_run, importer_log)), + ExistingParticipationChecker(test_run, importer_log), ]: checker.check_rows(parsed_rows) @@ -635,7 +686,7 @@ def normalize_rows( assert all_fields_valid(row.course_data) course_data = course_data_by_name_en.setdefault(row.course_data.name_en, row.course_data) - assert course_data.equals_when_ignoring_degrees(row.course_data) + assert course_data.differing_fields(row.course_data) <= {"degrees"} # Not a checker to keep merging and notifying about the merge together. if not row.course_data.degrees.issubset(course_data.degrees): @@ -756,13 +807,25 @@ def store_participations_in_db(enrollment_rows: Iterable[EnrollmentParsedRow]): for evaluation in Evaluation.objects.select_related("course").filter(course__name_en__in=course_names_en) } - participants_through_objects = [ - Evaluation.participants.through( - evaluation=evaluations_by_course_name_en[row.course_data.name_en], - userprofile=users_by_email[row.student_data.email], - ) + course_id_participant_id_pairs_in_file = { + (evaluations_by_course_name_en[row.course_data.name_en].pk, users_by_email[row.student_data.email].pk) for row in enrollment_rows + } + + existing_course_id_participant_id_pairs = { + (participation.evaluation_id, participation.userprofile_id) + for participation in Evaluation.participants.through.objects.filter( + evaluation__in=evaluations_by_course_name_en.values() + ) + } + + course_id_participant_id_pairs = course_id_participant_id_pairs_in_file - existing_course_id_participant_id_pairs + + participants_through_objects = [ + Evaluation.participants.through(evaluation_id=evaluation_id, userprofile_id=userprofile_id) + for (evaluation_id, userprofile_id) in course_id_participant_id_pairs ] + Evaluation.participants.through.objects.bulk_create(participants_through_objects) Evaluation.update_log_after_m2m_bulk_create( evaluations_by_course_name_en.values(), diff --git a/evap/staff/templates/staff_evaluation_textanswers_quick.html b/evap/staff/templates/staff_evaluation_textanswers_quick.html index 4062ae0ecc..cbb8e1febc 100644 --- a/evap/staff/templates/staff_evaluation_textanswers_quick.html +++ b/evap/staff/templates/staff_evaluation_textanswers_quick.html @@ -34,8 +34,6 @@ {% csrf_token %} - -
{% trans 'Text answers' %} diff --git a/evap/staff/templates/staff_evaluation_textanswers_section.html b/evap/staff/templates/staff_evaluation_textanswers_section.html index 20f31364ef..e723ab719d 100644 --- a/evap/staff/templates/staff_evaluation_textanswers_section.html +++ b/evap/staff/templates/staff_evaluation_textanswers_section.html @@ -28,7 +28,7 @@
{% if user.is_manager %} - + {% endif %} @@ -36,7 +36,6 @@ {% csrf_token %} -
diff --git a/evap/staff/templates/staff_message_rendering_template.html b/evap/staff/templates/staff_message_rendering_template.html index e7c30f8baa..9a52675a3d 100644 --- a/evap/staff/templates/staff_message_rendering_template.html +++ b/evap/staff/templates/staff_message_rendering_template.html @@ -8,12 +8,12 @@ {% for category, errorlist in importer_log.errors_by_category.items %}
-
+
{% for error in errorlist %}
@@ -29,12 +29,12 @@ {% for category, warninglist in importer_log.warnings_by_category.items %}
-
+
{% for warning in warninglist %}
diff --git a/evap/staff/tests/test_importers.py b/evap/staff/tests/test_importers.py index b8de6e9612..27524491e2 100644 --- a/evap/staff/tests/test_importers.py +++ b/evap/staff/tests/test_importers.py @@ -1,3 +1,4 @@ +from copy import deepcopy from datetime import date, datetime from unittest.mock import patch @@ -72,6 +73,14 @@ def test_created_users(self): self.assertTrue(UserProfile.objects.filter(email="lucilia.manilium@institution.example.com").exists()) self.assertTrue(UserProfile.objects.filter(email="bastius.quid@external.example.com").exists()) + @patch("evap.staff.importers.user.clean_email", new=(lambda email: "cleaned_" + email)) + def test_emails_are_cleaned(self): + original_user_count = UserProfile.objects.count() + __, __ = import_users(self.valid_excel_file_content, test_run=False) + self.assertEqual(UserProfile.objects.count(), 2 + original_user_count) + self.assertTrue(UserProfile.objects.filter(email="cleaned_lucilia.manilium@institution.example.com").exists()) + self.assertTrue(UserProfile.objects.filter(email="cleaned_bastius.quid@external.example.com").exists()) + def test_duplicate_warning(self): user = baker.make(UserProfile, first_name="Lucilia", last_name="Manilium", email="luma@institution.example.com") @@ -316,6 +325,23 @@ def test_valid_file_import(self): expected_user_count = old_user_count + 23 self.assertEqual(UserProfile.objects.all().count(), expected_user_count) + @patch("evap.staff.importers.user.clean_email", new=(lambda email: "cleaned_" + email)) + @patch("evap.staff.importers.enrollment.clean_email", new=(lambda email: "cleaned_" + email)) + def test_emails_are_cleaned(self): + import_enrollments(self.default_excel_content, self.semester, None, None, test_run=True) + + old_user_count = UserProfile.objects.all().count() + + import_enrollments( + self.default_excel_content, self.semester, self.vote_start_datetime, self.vote_end_date, test_run=False + ) + expected_user_count = old_user_count + 23 + self.assertEqual(UserProfile.objects.all().count(), expected_user_count) + + self.assertTrue(UserProfile.objects.filter(email="cleaned_bastius.quid@external.example.com").exists()) + self.assertTrue(UserProfile.objects.filter(email="cleaned_diam.synephebos@institution.example.com").exists()) + self.assertTrue(UserProfile.objects.filter(email="cleaned_111@institution.example.com").exists()) + def test_degrees_are_merged(self): excel_content = excel_data.create_memory_excel_file(excel_data.test_enrollment_data_degree_merge_filedata) @@ -450,7 +476,7 @@ def test_invalid_file_error(self): [msg.message for msg in importer_log_test.errors_by_category()[ImporterLogEntry.Category.COURSE]], [ 'Sheet "MA Belegungen", row 18: The German name for course "Bought" is already used for another course in the import file.', - 'Sheet "MA Belegungen", row 20: The data of course "Cost" differs from its data in a previous row.', + 'Sheet "MA Belegungen", row 20: The data of course "Cost" differs from its data in the columns (responsible_email) in a previous row.', ], ) self.assertEqual( @@ -669,6 +695,60 @@ def test_user_data_mismatch_to_database(self): import_enrollments(excel_content, self.semester, None, None, test_run=True) self.assertGreater(mock.call_count, 50) + def test_duplicate_participation(self): + input_data = deepcopy(excel_data.test_enrollment_data_filedata) + # create a duplicate participation by duplicating a line + input_data["MA Belegungen"].append(input_data["MA Belegungen"][1]) + excel_content = excel_data.create_memory_excel_file(input_data) + + importer_log = import_enrollments(excel_content, self.semester, None, None, test_run=True) + self.assertEqual(importer_log.errors_by_category(), {}) + self.assertEqual(importer_log.warnings_by_category(), {}) + + old_user_count = UserProfile.objects.all().count() + + importer_log = import_enrollments( + self.default_excel_content, self.semester, self.vote_start_datetime, self.vote_end_date, test_run=False + ) + self.assertEqual(importer_log.errors_by_category(), {}) + self.assertEqual(importer_log.warnings_by_category(), {}) + + self.assertEqual(Evaluation.objects.all().count(), 23) + expected_user_count = old_user_count + 23 + self.assertEqual(UserProfile.objects.all().count(), expected_user_count) + + def test_existing_participation(self): + _, existing_evaluation = self.create_existing_course() + user = baker.make( + UserProfile, first_name="Lucilia", last_name="Manilium", email="lucilia.manilium@institution.example.com" + ) + existing_evaluation.participants.add(user) + + importer_log = import_enrollments(self.default_excel_content, self.semester, None, None, test_run=True) + + expected_warnings = ["Course Shake: 1 participants from the import file already participate in the evaluation."] + self.assertEqual( + [ + msg.message + for msg in importer_log.warnings_by_category()[ImporterLogEntry.Category.ALREADY_PARTICIPATING] + ], + expected_warnings, + ) + self.assertFalse(importer_log.has_errors()) + + importer_log = import_enrollments( + self.default_excel_content, self.semester, self.vote_start_datetime, self.vote_end_date, test_run=False + ) + + self.assertEqual( + [ + msg.message + for msg in importer_log.warnings_by_category()[ImporterLogEntry.Category.ALREADY_PARTICIPATING] + ], + expected_warnings, + ) + self.assertFalse(importer_log.has_errors()) + class TestPersonImport(TestCase): @classmethod diff --git a/evap/staff/tests/test_views.py b/evap/staff/tests/test_views.py index d5f8450ba7..7a2c57e29b 100644 --- a/evap/staff/tests/test_views.py +++ b/evap/staff/tests/test_views.py @@ -556,6 +556,8 @@ def test_warning_handling(self): form["excel_file"] = ("import.xls", self.valid_excel_file_content) reply = form.submit(name="operation", value="test") + + self.assertContains(reply, "Name mismatches") self.assertContains( reply, "The existing user would be overwritten with the following data:
" @@ -1075,6 +1077,7 @@ def test_warning_handling(self): ) reply = form.submit(name="operation", value="test") + self.assertContains(reply, "Name mismatches") self.assertContains( reply, "The existing user would be overwritten with the following data:
" @@ -2270,6 +2273,7 @@ def test_import_contributors_warning_handling(self): form["ce-excel_file"] = ("import.xls", self.valid_excel_file_content) reply = form.submit(name="operation", value="test-contributors") + self.assertContains(reply, "Name mismatches") self.assertContains( reply, "The existing user would be overwritten with the following data:
" @@ -2561,7 +2565,7 @@ def setUpTestData(cls): cls.url = reverse( "staff:evaluation_textanswer_edit", - args=(cls.evaluation.course.semester.pk, cls.evaluation.pk, cls.textanswer.pk), + args=[cls.textanswer.pk], ) def test_textanswers_showing_up(self): @@ -3019,8 +3023,8 @@ def assert_transition( expected_new_decision = old_decision if expected_new_decision == "unchanged" else expected_new_decision with run_in_staff_mode(self): - textanswer = baker.make(TextAnswer, review_decision=old_decision) - params = {"answer_id": textanswer.id, "action": action, "evaluation_id": self.evaluation.pk} + textanswer = baker.make(TextAnswer, contribution__evaluation=self.evaluation, review_decision=old_decision) + params = {"answer_id": textanswer.id, "action": action} response = self.app.post(self.url, params=params, user=self.manager, status=status) textanswer.refresh_from_db() @@ -3045,7 +3049,7 @@ def test_review_actions(self): TextAnswer.ReviewDecision.UNDECIDED, status=302, ) - self.assertRegex(response.location, r"/staff/semester/\d+/evaluation/\d+/textanswer/[0-9a-f\-]+/edit$") + self.assertRegex(response.location, r"/staff/textanswer/[0-9a-f\-]+/edit$") def test_invalid_action(self): let_user_vote_for_evaluation(self.student2, self.evaluation) @@ -3156,6 +3160,12 @@ def test_emailtemplate(self): self.assertEqual(self.template.plain_content, "plain_content: mflkd862xmnbo5") self.assertEqual(self.template.html_content, "html_content:

mflkd862xmnbo5

") + def test_review_reminder_template_tag(self): + review_reminder_template = EmailTemplate.objects.get(name=EmailTemplate.TEXT_ANSWER_REVIEW_REMINDER) + page = self.app.get(f"/staff/template/{review_reminder_template.pk}", user=self.manager, status=200) + + self.assertContains(page, "evaluation_url_tuples") + class TestTextAnswerWarningsView(WebTestStaffMode): url = "/staff/text_answer_warnings/" diff --git a/evap/staff/urls.py b/evap/staff/urls.py index 8ec30243fe..2fb9a030b5 100644 --- a/evap/staff/urls.py +++ b/evap/staff/urls.py @@ -31,7 +31,6 @@ path("semester//evaluation//person_management", views.evaluation_person_management, name="evaluation_person_management"), path("semester//evaluation//login_key_export", views.evaluation_login_key_export, name="evaluation_login_key_export"), path("semester//evaluation//textanswers", views.evaluation_textanswers, name="evaluation_textanswers"), - path("semester//evaluation//textanswer//edit", views.evaluation_textanswer_edit, name="evaluation_textanswer_edit"), path("semester//evaluationoperation", views.semester_evaluation_operation, name="semester_evaluation_operation"), path("semester//singleresult/create", views.single_result_create, name="single_result_create"), path("semester//singleresult/create/", views.single_result_create, name="single_result_create"), @@ -45,6 +44,7 @@ path("semester/evaluation_delete", views.evaluation_delete, name="evaluation_delete"), path("semester/make_active", views.semester_make_active, name="semester_make_active"), + path("textanswer//edit", views.evaluation_textanswer_edit, name="evaluation_textanswer_edit"), path("textanswers/update_publish", views.evaluation_textanswers_update_publish, name="evaluation_textanswers_update_publish"), path("textanswers/skip", views.evaluation_textanswers_skip, name="evaluation_textanswers_skip"), diff --git a/evap/staff/views.py b/evap/staff/views.py index ac9afa30c3..04c43952c9 100644 --- a/evap/staff/views.py +++ b/evap/staff/views.py @@ -1527,13 +1527,7 @@ def evaluation_textanswers_skip(request): return HttpResponse() -@require_POST -@reviewer_required -def evaluation_textanswers_update_publish(request): - answer = get_object_from_dict_pk_entry_or_logged_40x(TextAnswer, request.POST, "answer_id") - evaluation = get_object_from_dict_pk_entry_or_logged_40x(Evaluation, request.POST, "evaluation_id") - action = request.POST.get("action", None) - +def assert_textanswer_review_permissions(evaluation: Evaluation) -> None: if evaluation.state == Evaluation.State.PUBLISHED: raise PermissionDenied if evaluation.course.semester.results_are_archived: @@ -1541,8 +1535,18 @@ def evaluation_textanswers_update_publish(request): if not evaluation.can_publish_text_results: raise PermissionDenied + +@require_POST +@reviewer_required +def evaluation_textanswers_update_publish(request): + answer = get_object_from_dict_pk_entry_or_logged_40x(TextAnswer, request.POST, "answer_id") + evaluation = answer.contribution.evaluation + action = request.POST.get("action", None) + + assert_textanswer_review_permissions(evaluation) + if action == "textanswer_edit": - return redirect("staff:evaluation_textanswer_edit", evaluation.course.semester.id, evaluation.pk, answer.pk) + return redirect("staff:evaluation_textanswer_edit", answer.pk) review_decision_for_action = { "publish": TextAnswer.ReviewDecision.PUBLIC, @@ -1568,27 +1572,24 @@ def evaluation_textanswers_update_publish(request): @manager_required -def evaluation_textanswer_edit(request, semester_id, evaluation_id, textanswer_id): - semester = get_object_or_404(Semester, id=semester_id) - if semester.results_are_archived: - raise PermissionDenied - evaluation = get_object_or_404(Evaluation, id=evaluation_id, course__semester=semester) +def evaluation_textanswer_edit(request, textanswer_id): + textanswer = get_object_or_404(TextAnswer, id=textanswer_id) + evaluation = textanswer.contribution.evaluation + assert_textanswer_review_permissions(evaluation) - if evaluation.state == Evaluation.State.PUBLISHED: - raise PermissionDenied - if not evaluation.can_publish_text_results: - raise PermissionDenied - - textanswer = get_object_or_404(TextAnswer, id=textanswer_id, contribution__evaluation=evaluation) form = TextAnswerForm(request.POST or None, instance=textanswer) if form.is_valid(): form.save() # jump to edited answer - url = reverse("staff:evaluation_textanswers", args=[semester_id, evaluation_id]) + "#" + str(textanswer.id) + url = ( + reverse("staff:evaluation_textanswers", args=[evaluation.course.semester.id, evaluation.id]) + + "#" + + str(textanswer.id) + ) return HttpResponseRedirect(url) - template_data = dict(semester=semester, evaluation=evaluation, form=form, textanswer=textanswer) + template_data = dict(semester=evaluation.course.semester, evaluation=evaluation, form=form, textanswer=textanswer) return render(request, "staff_evaluation_textanswer_edit.html", template_data) @@ -2212,9 +2213,10 @@ def template_edit(request, template_id): EmailTemplate.EDITOR_REVIEW_REMINDER, EmailTemplate.PUBLISHING_NOTICE_CONTRIBUTOR, EmailTemplate.PUBLISHING_NOTICE_PARTICIPANT, - EmailTemplate.TEXT_ANSWER_REVIEW_REMINDER, ]: available_variables += ["evaluations"] + elif template.name == EmailTemplate.TEXT_ANSWER_REVIEW_REMINDER: + available_variables += ["evaluation_url_tuples"] elif template.name == EmailTemplate.EVALUATION_STARTED: available_variables += ["evaluations", "due_evaluations"] elif template.name == EmailTemplate.DIRECT_DELEGATION: diff --git a/evap/student/templates/student_index_semester_evaluations_list.html b/evap/student/templates/student_index_semester_evaluations_list.html index b35198f860..5e8147972c 100644 --- a/evap/student/templates/student_index_semester_evaluations_list.html +++ b/evap/student/templates/student_index_semester_evaluations_list.html @@ -66,7 +66,7 @@ {% for evaluation in evaluations|dictsort:"name" %}