diff --git a/deployment/localsettings.template.py b/deployment/localsettings.template.py index 270f4df29..2e469f457 100644 --- a/deployment/localsettings.template.py +++ b/deployment/localsettings.template.py @@ -1,3 +1,7 @@ +from fractions import Fraction + +from django.utils.safestring import mark_safe + DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', # postgresql', 'mysql', 'sqlite3' or 'oracle'. @@ -15,3 +19,18 @@ # Make apache work when DEBUG == False ALLOWED_HOSTS = ["localhost", "127.0.0.1"] + +### Evaluation progress rewards +GLOBAL_EVALUATION_PROGRESS_REWARDS: list[tuple[Fraction, str]] = [ + (Fraction("0"), "0€"), + (Fraction("0.25"), "1.000€"), + (Fraction("0.6"), "3.000€"), + (Fraction("0.7"), "7.000€"), + (Fraction("0.9"), "10.000€"), +] +GLOBAL_EVALUATION_PROGRESS_EXCLUDED_COURSE_TYPE_IDS: list[int] = [] +GLOBAL_EVALUATION_PROGRESS_EXCLUDED_EVALUATION_IDS: list[int] = [] +GLOBAL_EVALUATION_PROGRESS_INFO_TEXT = { + "de": mark_safe("Deine Teilnahme am Evaluationsprojekt wird helfen. Evaluiere also jetzt!"), + "en": mark_safe("Your participation in the evaluation helps, so evaluate now!"), +} diff --git a/evap/evaluation/migrations/0144_alter_evaluation_state.py b/evap/evaluation/migrations/0144_alter_evaluation_state.py new file mode 100644 index 000000000..2bfbb3f1a --- /dev/null +++ b/evap/evaluation/migrations/0144_alter_evaluation_state.py @@ -0,0 +1,33 @@ +# Generated by Django 5.0.6 on 2024-06-17 23:00 + +import django_fsm +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("evaluation", "0143_alter_evaluation_state"), + ] + + operations = [ + migrations.AlterField( + model_name="evaluation", + name="state", + field=django_fsm.FSMIntegerField( + choices=[ + (10, "new"), + (20, "prepared"), + (30, "editor approved"), + (40, "approved"), + (50, "in evaluation"), + (60, "evaluated"), + (70, "reviewed"), + (80, "published"), + ], + default=10, + protected=True, + verbose_name="state", + ), + ), + ] diff --git a/evap/evaluation/models_logging.py b/evap/evaluation/models_logging.py index d82b6f7bb..bcbaa6c55 100644 --- a/evap/evaluation/models_logging.py +++ b/evap/evaluation/models_logging.py @@ -15,6 +15,7 @@ from django.template.defaultfilters import yesno from django.utils.formats import localize from django.utils.translation import gettext_lazy as _ +from typing_extensions import assert_never from evap.evaluation.tools import capitalize_first @@ -100,18 +101,21 @@ def field_context_data(self): @property def message(self): - if self.action_type == InstanceActionType.CHANGE: - if self.content_object: - message = _("The {cls} {obj} was changed.") - else: # content_object might be deleted - message = _("A {cls} was changed.") - elif self.action_type == InstanceActionType.CREATE: - if self.content_object: - message = _("The {cls} {obj} was created.") - else: - message = _("A {cls} was created.") - elif self.action_type == InstanceActionType.DELETE: - message = _("A {cls} was deleted.") + match self.action_type: + case InstanceActionType.CHANGE: + if self.content_object: + message = _("The {cls} {obj} was changed.") + else: # content_object might be deleted + message = _("A {cls} was changed.") + case InstanceActionType.CREATE: + if self.content_object: + message = _("The {cls} {obj} was created.") + else: + message = _("A {cls} was created.") + case InstanceActionType.DELETE: + message = _("A {cls} was deleted.") + case _: + assert_never(self.action_type) return message.format( cls=capitalize_first(self.content_type.model_class()._meta.verbose_name), diff --git a/evap/evaluation/templates/base.html b/evap/evaluation/templates/base.html index 53f7c11ce..9cc581b22 100644 --- a/evap/evaluation/templates/base.html +++ b/evap/evaluation/templates/base.html @@ -148,7 +148,7 @@ const baseOptions = { createOnBlur: true, placeholder: "{% translate 'Please select...' %}", - hidePlaceholder: true, + hidePlaceholder: element.hasAttribute("data-tomselect-fullwidth") ? false : true, minimumInputLength, render: { option_create: (data, escape) => `
${ escape(data.input) }
`, diff --git a/evap/evaluation/templatetags/evaluation_filters.py b/evap/evaluation/templatetags/evaluation_filters.py index c7b4df28f..915494349 100644 --- a/evap/evaluation/templatetags/evaluation_filters.py +++ b/evap/evaluation/templatetags/evaluation_filters.py @@ -108,6 +108,14 @@ def percentage_one_decimal(fraction, population): return None +@register.filter +def percentage_zero_on_error(fraction, population): + try: + return f"{int(float(fraction) / float(population) * 100):.0f}%" + except (ZeroDivisionError, ValueError): + return "0%" + + @register.filter def to_colors(question_result: RatingResult | None): if question_result is None: diff --git a/evap/locale/de/LC_MESSAGES/django.po b/evap/locale/de/LC_MESSAGES/django.po index 7507e3f9a..110eef430 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: 2024-06-17 20:33+0200\n" -"PO-Revision-Date: 2024-06-17 20:33+0200\n" +"POT-Creation-Date: 2024-07-06 13:53+0200\n" +"PO-Revision-Date: 2024-07-06 13:55+0200\n" "Last-Translator: Johannes Wolf \n" "Language-Team: Johannes Wolf (janno42)\n" "Language: de\n" @@ -336,7 +336,7 @@ msgstr "Entwicklung" #: evap/evaluation/forms.py:19 evap/staff/templates/staff_user_list.html:45 #: evap/staff/templates/staff_user_merge.html:56 evap/staff/views.py:777 -#: evap/staff/views.py:1507 evap/staff/views.py:2141 +#: evap/staff/views.py:1508 evap/staff/views.py:2142 msgid "Email" msgstr "E‑Mail" @@ -357,7 +357,7 @@ msgstr "" "zur Anmeldung benötigt." #: evap/evaluation/forms.py:63 evap/rewards/exporters.py:14 -#: evap/rewards/views.py:168 +#: evap/rewards/views.py:173 msgid "Email address" msgstr "E‑Mail-Adresse" @@ -1332,7 +1332,7 @@ msgid "email address" msgstr "E‑Mail-Adresse" #: evap/evaluation/models.py:1651 evap/staff/templates/staff_user_merge.html:32 -#: evap/staff/views.py:2141 +#: evap/staff/views.py:2142 msgid "Title" msgstr "Titel" @@ -1458,31 +1458,31 @@ msgstr "" msgid "vote timestamp" msgstr "Zeitstempel der Abstimmung" -#: evap/evaluation/models_logging.py:67 +#: evap/evaluation/models_logging.py:68 msgid "" msgstr "" -#: evap/evaluation/models_logging.py:105 +#: evap/evaluation/models_logging.py:107 #, python-brace-format msgid "The {cls} {obj} was changed." msgstr "{cls} {obj} wurde geändert." -#: evap/evaluation/models_logging.py:107 +#: evap/evaluation/models_logging.py:109 #, python-brace-format msgid "A {cls} was changed." msgstr "{cls} wurde geändert." -#: evap/evaluation/models_logging.py:110 +#: evap/evaluation/models_logging.py:112 #, python-brace-format msgid "The {cls} {obj} was created." msgstr "{cls} {obj} wurde erstellt." -#: evap/evaluation/models_logging.py:112 +#: evap/evaluation/models_logging.py:114 #, python-brace-format msgid "A {cls} was created." msgstr "{cls} wurde erstellt." -#: evap/evaluation/models_logging.py:114 +#: evap/evaluation/models_logging.py:116 #, python-brace-format msgid "A {cls} was deleted." msgstr "{cls} wurde gelöscht." @@ -2619,16 +2619,16 @@ msgid "Redemptions" msgstr "Einlösungen" #: evap/rewards/exporters.py:12 evap/staff/templates/staff_user_merge.html:50 -#: evap/staff/views.py:1507 evap/staff/views.py:2141 +#: evap/staff/views.py:1508 evap/staff/views.py:2142 msgid "Last name" msgstr "Nachname" #: evap/rewards/exporters.py:13 evap/staff/templates/staff_user_merge.html:38 -#: evap/staff/views.py:1507 evap/staff/views.py:2141 +#: evap/staff/views.py:1508 evap/staff/views.py:2142 msgid "First name" msgstr "Vorname" -#: evap/rewards/exporters.py:15 evap/rewards/views.py:168 +#: evap/rewards/exporters.py:15 evap/rewards/views.py:173 msgid "Number of points" msgstr "Anzahl der Punkte" @@ -2839,23 +2839,23 @@ msgid "" "well." msgstr "Wir freuen uns auch auf dein Feedback in den anderen Evaluierungen." -#: evap/rewards/views.py:48 +#: evap/rewards/views.py:49 msgid "You successfully redeemed your points." msgstr "Punkte erfolgreich eingelöst." -#: evap/rewards/views.py:57 +#: evap/rewards/views.py:59 msgid "You cannot redeem 0 points." msgstr "Du kannst nicht 0 Punkte einlösen." -#: evap/rewards/views.py:59 +#: evap/rewards/views.py:61 msgid "You don't have enough reward points." msgstr "Du hast nicht genügend Belohnungspunkte." -#: evap/rewards/views.py:61 +#: evap/rewards/views.py:63 msgid "Sorry, the deadline for this event expired already." msgstr "Sorry, die Frist für diese Veranstaltung ist bereits abgelaufen." -#: evap/rewards/views.py:65 +#: evap/rewards/views.py:67 msgid "" "It appears that your browser sent multiple redemption requests. You can see " "all successful redemptions below." @@ -2863,19 +2863,19 @@ msgstr "" "Dein Browser scheint mehrere Anfragen zum Einlösen geschickt zu haben. Du " "kannst alle erfolgreichen Einlösungen unten sehen." -#: evap/rewards/views.py:83 +#: evap/rewards/views.py:88 msgid "Reward for" msgstr "Belohnung für" -#: evap/rewards/views.py:125 +#: evap/rewards/views.py:130 msgid "Successfully created event." msgstr "Veranstaltung erfolgreich erstellt." -#: evap/rewards/views.py:134 +#: evap/rewards/views.py:139 msgid "Successfully updated event." msgstr "Veranstaltung erfolgreich geändert." -#: evap/rewards/views.py:154 evap/rewards/views.py:164 +#: evap/rewards/views.py:159 evap/rewards/views.py:169 msgid "RewardPoints" msgstr "Belohnungspunkte" @@ -5458,50 +5458,50 @@ msgstr "E-Mails für '%s' wurden erfolgreich versendet." msgid "{} participants were deleted from evaluation {}" msgstr "{} Teilnehmende wurden aus der Evaluierung {} entfernt" -#: evap/staff/views.py:1396 +#: evap/staff/views.py:1397 msgid "{} contributors were deleted from evaluation {}" msgstr "{} Mitwirkende wurden aus der Evaluierung {} entfernt" -#: evap/staff/views.py:1507 +#: evap/staff/views.py:1508 msgid "Login key" msgstr "Anmeldeschlüssel" -#: evap/staff/views.py:1782 evap/staff/views.py:1884 evap/staff/views.py:1929 +#: evap/staff/views.py:1783 evap/staff/views.py:1885 evap/staff/views.py:1930 msgid "Successfully created questionnaire." msgstr "Fragebogen erfolgreich erstellt." -#: evap/staff/views.py:1848 +#: evap/staff/views.py:1849 msgid "Successfully updated questionnaire." msgstr "Fragebogen erfolgreich geändert." -#: evap/staff/views.py:1904 +#: evap/staff/views.py:1905 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:2014 +#: evap/staff/views.py:2015 msgid "Successfully updated the degrees." msgstr "Studiengänge erfolgreich geändert." -#: evap/staff/views.py:2029 +#: evap/staff/views.py:2030 msgid "Successfully updated the course types." msgstr "Veranstaltungstypen erfolgreich geändert." -#: evap/staff/views.py:2054 +#: evap/staff/views.py:2055 msgid "Successfully merged course types." msgstr "Veranstaltungstypen erfolgreich zusammengeführt." -#: evap/staff/views.py:2076 +#: evap/staff/views.py:2077 msgid "Successfully updated text warning answers." msgstr "Textantwort-Warnungen erfolgreich geändert." -#: evap/staff/views.py:2155 +#: evap/staff/views.py:2156 msgid "Successfully created user." msgstr "Account erfolgreich erstellt." -#: evap/staff/views.py:2210 +#: evap/staff/views.py:2211 #, python-brace-format msgid "" "The removal of evaluations has granted the user \"{granting.user_profile." @@ -5518,19 +5518,19 @@ msgstr[1] "" "user_profile.email}\" {granting.value} Belohnungspunkte für das aktive " "Semester vergeben." -#: evap/staff/views.py:2227 +#: evap/staff/views.py:2228 msgid "Successfully updated user." msgstr "Account erfolgreich geändert." -#: evap/staff/views.py:2253 +#: evap/staff/views.py:2254 msgid "Successfully deleted user." msgstr "Account erfolgreich gelöscht." -#: evap/staff/views.py:2270 +#: evap/staff/views.py:2271 msgid "Successfully resent evaluation started email." msgstr "E-Mail über den Evaluierungsbeginn erfolgreich erneut gesendet." -#: evap/staff/views.py:2299 +#: evap/staff/views.py:2300 msgid "" "An error happened when processing the file. Make sure the file meets the " "requirements." @@ -5538,29 +5538,29 @@ msgstr "" "Ein Fehler ist bei der Verarbeitung der Datei aufgetreten. Stelle sicher, " "dass die Datei allen Anforderungen genügt." -#: evap/staff/views.py:2364 +#: evap/staff/views.py:2365 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:2366 +#: evap/staff/views.py:2367 msgid "Successfully merged users." msgstr "Accounts erfolgreich zusammengeführt." -#: evap/staff/views.py:2388 +#: evap/staff/views.py:2389 msgid "Successfully updated template." msgstr "Vorlage erfolgreich geändert." -#: evap/staff/views.py:2433 +#: evap/staff/views.py:2434 msgid "Successfully updated the FAQ sections." msgstr "FAQ-Abschnitte erfolgreich geändert." -#: evap/staff/views.py:2451 +#: evap/staff/views.py:2452 msgid "Successfully updated the FAQ questions." msgstr "FAQ-Fragen erfolgreich geändert." -#: evap/staff/views.py:2463 +#: evap/staff/views.py:2464 msgid "Successfully updated the infotext entries." msgstr "Infotexte erfolgreich geändert." @@ -5568,7 +5568,27 @@ msgstr "Infotexte erfolgreich geändert." msgid "Warning order" msgstr "Reihenfolge" -#: evap/student/templates/student_index.html:23 +#: evap/student/templates/student_global_reward.html:6 +msgid "Fundraising" +msgstr "Spendenaktion" + +#: evap/student/templates/student_global_reward.html:9 +#, python-format +msgid "Last evaluation: %(time_interval)s ago" +msgstr "Letzte Evaluierung: Vor %(time_interval)s" + +#: evap/student/templates/student_global_reward.html:21 +#, python-format +msgid "%(votes)s submitted evaluation (%(percent)s)" +msgid_plural "%(votes)s submitted evaluations (%(percent)s)" +msgstr[0] "%(votes)s abgesendete Evaluierung (%(percent)s)" +msgstr[1] "%(votes)s abgesendete Evaluierungen (%(percent)s)" + +#: evap/student/templates/student_global_reward.html:42 +msgid "Every vote counts! Read more about our fundraising goal." +msgstr "Jede Stimme zählt! Erfahre mehr über unser Spendenziel." + +#: evap/student/templates/student_index.html:25 msgid "Open and upcoming evaluations" msgstr "Laufende und bevorstehende Evaluierungen" @@ -5812,6 +5832,6 @@ msgstr "Textantwort zu dieser Frage hinzufügen" msgid "After publishing, this text answer can be seen by:" msgstr "Nach dem Veröffentlichen können diese Textantwort sehen:" -#: evap/student/views.py:256 +#: evap/student/views.py:325 msgid "Your vote was recorded." msgstr "Deine Antworten wurden gespeichert." diff --git a/evap/locale/de/LC_MESSAGES/djangojs.po b/evap/locale/de/LC_MESSAGES/djangojs.po index e102f4bd8..56e6ceb93 100644 --- a/evap/locale/de/LC_MESSAGES/djangojs.po +++ b/evap/locale/de/LC_MESSAGES/djangojs.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: EvaP\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-06-17 20:37+0200\n" +"POT-Creation-Date: 2024-07-06 13:53+0200\n" "PO-Revision-Date: 2023-12-18 18:41+0100\n" "Last-Translator: Johannes Wolf \n" "Language-Team: Johannes Wolf (janno42)\n" @@ -22,10 +22,10 @@ msgstr "" msgid "The server is not responding." msgstr "Der Server reagiert nicht." -#: evap/static/js/sortable-form.js:23 evap/static/js/sortable_form.js:19 +#: evap/static/js/sortable_form.js:19 msgid "Delete" msgstr "Löschen" -#: evap/static/js/sortable-form.js:24 evap/static/js/sortable_form.js:20 +#: evap/static/js/sortable_form.js:20 msgid "add another" msgstr "Weitere·n hinzufügen" diff --git a/evap/rewards/views.py b/evap/rewards/views.py index 724d515c0..f5cbbd261 100644 --- a/evap/rewards/views.py +++ b/evap/rewards/views.py @@ -13,6 +13,7 @@ from django.utils.translation import gettext_lazy from django.views.decorators.http import require_POST from django.views.generic import CreateView, UpdateView +from typing_extensions import assert_never from evap.evaluation.auth import manager_required, reward_user_required from evap.evaluation.models import Semester, UserProfile @@ -53,17 +54,21 @@ def redeem_reward_points(request): OutdatedRedemptionDataError, ) as error: status_code = 400 - if isinstance(error, NoPointsSelectedError): - error_string = _("You cannot redeem 0 points.") - elif isinstance(error, NotEnoughPointsError): - error_string = _("You don't have enough reward points.") - elif isinstance(error, RedemptionEventExpiredError): - error_string = _("Sorry, the deadline for this event expired already.") - elif isinstance(error, OutdatedRedemptionDataError): - status_code = 409 - error_string = _( - "It appears that your browser sent multiple redemption requests. You can see all successful redemptions below." - ) + match error: + case NoPointsSelectedError(): + error_string = _("You cannot redeem 0 points.") + case NotEnoughPointsError(): + error_string = _("You don't have enough reward points.") + case RedemptionEventExpiredError(): + error_string = _("Sorry, the deadline for this event expired already.") + case OutdatedRedemptionDataError(): + status_code = 409 + error_string = _( + "It appears that your browser sent multiple redemption requests. You can see all successful redemptions below." + ) + case _: + assert_never(type(error)) + messages.error(request, error_string) return status_code return 200 diff --git a/evap/settings.py b/evap/settings.py index cc5a45134..be20a246f 100644 --- a/evap/settings.py +++ b/evap/settings.py @@ -11,6 +11,7 @@ import logging import os import sys +from fractions import Fraction from typing import Any from django.contrib.staticfiles.storage import ManifestStaticFilesStorage @@ -352,6 +353,13 @@ class ManifestStaticFilesStorageWithJsReplacement(ManifestStaticFilesStorage): # Absolute filesystem path to the directory that will hold user-uploaded files. MEDIA_ROOT = os.path.join(BASE_DIR, "upload") +### Evaluation progress rewards +GLOBAL_EVALUATION_PROGRESS_REWARDS: list[tuple[Fraction, str]] = ( + [] +) # (required_voter_ratio between 0 and 1, reward_text) +GLOBAL_EVALUATION_PROGRESS_EXCLUDED_COURSE_TYPE_IDS: list[int] = [] +GLOBAL_EVALUATION_PROGRESS_EXCLUDED_EVALUATION_IDS: list[int] = [] +GLOBAL_EVALUATION_PROGRESS_INFO_TEXT: dict[str, str] = {"de": "", "en": ""} ### Slogans SLOGANS_DE = [ diff --git a/evap/staff/tools.py b/evap/staff/tools.py index 5bdf8822e..23b10fe42 100644 --- a/evap/staff/tools.py +++ b/evap/staff/tools.py @@ -100,8 +100,8 @@ def find_matching_internal_user_for_email(request, email): return matching_users[0] -def bulk_update_users(request, user_file_content, test_run): - # pylint: disable=too-many-branches,too-many-locals +def bulk_update_users(request, user_file_content, test_run): # noqa: PLR0912 + # pylint: disable=too-many-locals # user_file must have one user per line in the format "{username},{email}" imported_emails = {clean_email(line.decode().split(",")[1]) for line in user_file_content.splitlines()} diff --git a/evap/staff/views.py b/evap/staff/views.py index 01c0630f0..7d29d1559 100644 --- a/evap/staff/views.py +++ b/evap/staff/views.py @@ -1391,7 +1391,8 @@ def helper_delete_users_from_evaluation(evaluation, operation): deleted_person_count = evaluation.participants.count() deletion_message = _("{} participants were deleted from evaluation {}") evaluation.participants.clear() - elif "contributors" in operation: + else: + assert "contributors" in operation deleted_person_count = evaluation.contributions.exclude(contributor=None).count() deletion_message = _("{} contributors were deleted from evaluation {}") evaluation.contributions.exclude(contributor=None).delete() diff --git a/evap/static/scss/_mixins.scss b/evap/static/scss/_mixins.scss index 16b27e403..f956071b6 100644 --- a/evap/static/scss/_mixins.scss +++ b/evap/static/scss/_mixins.scss @@ -1,7 +1,7 @@ @import "../bootstrap/scss/mixins"; @mixin bar-shadow($color) { - text-shadow: 0 0 4px $color, 0 0 4px $color, 0 0 2px $color; + text-shadow: 0 0 2px $color, 0 0 2px $color, 0 0 2px $color, 0 0 2px $color, 0 0 2px $color, 0 0 2px $color; } @mixin no-user-select { diff --git a/evap/static/scss/_utilities.scss b/evap/static/scss/_utilities.scss index 11a49eed9..17e0ca688 100644 --- a/evap/static/scss/_utilities.scss +++ b/evap/static/scss/_utilities.scss @@ -84,4 +84,8 @@ a.no-underline:hover { .width-percent-#{$i} { width: $i * 1% !important; } + + .left-percent-#{$i} { + left: $i * 1% !important; + } } diff --git a/evap/static/scss/components/_card.scss b/evap/static/scss/components/_card.scss index d63653408..51fb211e1 100644 --- a/evap/static/scss/components/_card.scss +++ b/evap/static/scss/components/_card.scss @@ -26,6 +26,14 @@ } } +.card-outline-light { + border-color: $light-gray; + + > .card-header { + background-color: rgba($lighter-gray, 0.5); + } +} + .card-noflex { flex: none; display: block; diff --git a/evap/static/scss/components/_progress.scss b/evap/static/scss/components/_progress.scss index 6eef3ce4a..3cdc7af69 100644 --- a/evap/static/scss/components/_progress.scss +++ b/evap/static/scss/components/_progress.scss @@ -54,3 +54,31 @@ .multi-progress-bar .progress-container { padding-bottom: 3px; } + +.global-rewards-progress-bar { + position: relative; + display: grid; + padding-top: .5rem; + padding-inline: 1.5rem; + + .progress-container { + display: flex; + height: 25px; + grid-column-start: 1; + grid-row-start: 1; + } +} + +.progress-step { + transform: translateX(-50%); + width: fit-content; + grid-column-start: 1; + grid-row-start: 2; + + .seperator { + width: 1px; + height: 8px; + border: $dark-gray 1px solid; + } +} + diff --git a/evap/static/scss/components/_transitions.scss b/evap/static/scss/components/_transitions.scss index 0b4bed90b..a77fdb602 100644 --- a/evap/static/scss/components/_transitions.scss +++ b/evap/static/scss/components/_transitions.scss @@ -25,3 +25,12 @@ transform: rotate(-90deg); } } + + +.collapse-toggle-light { + font-weight: normal; + + &:hover { + text-decoration: none; + } +} diff --git a/evap/static/ts/src/infobox.ts b/evap/static/ts/src/infobox.ts index d2a60057f..159f84429 100644 --- a/evap/static/ts/src/infobox.ts +++ b/evap/static/ts/src/infobox.ts @@ -6,6 +6,7 @@ export class InfoboxLogic { private readonly infobox: HTMLDivElement; private readonly closeButton: HTMLButtonElement; private readonly storageKey: string; + private timeout?: number; constructor(infobox_id: string) { this.infobox = selectOrError("#infobox-" + infobox_id); @@ -17,6 +18,8 @@ export class InfoboxLogic { // close the infobox and save state this.closeButton.addEventListener("click", event => { this.infobox.classList.add("closing"); + this.infobox.classList.remove("opening"); + clearTimeout(this.timeout); setTimeout(() => { this.infobox.classList.replace("closing", "closed"); }, OPEN_CLOSE_TIMEOUT); @@ -28,7 +31,7 @@ export class InfoboxLogic { this.infobox.addEventListener("click", _ => { if (this.infobox.className.includes("closed")) { this.infobox.classList.replace("closed", "opening"); - setTimeout(() => { + this.timeout = setTimeout(() => { this.infobox.classList.remove("opening"); }, OPEN_CLOSE_TIMEOUT); localStorage[this.storageKey] = "show"; diff --git a/evap/static/ts/tsconfig.compile.json b/evap/static/ts/tsconfig.compile.json index d44c76f3e..0984ea111 100644 --- a/evap/static/ts/tsconfig.compile.json +++ b/evap/static/ts/tsconfig.compile.json @@ -4,7 +4,12 @@ "rootDir": "./src", "outDir": "../../static/js", "incremental": true, - "tsBuildInfoFile": ".tsbuildinfo.json" + "tsBuildInfoFile": ".tsbuildinfo.json", + "types": [ + "bootstrap", + "jquery", + "sortablejs" + ] }, "include": ["src/**/*.ts"] } diff --git a/evap/student/forms.py b/evap/student/forms.py index dccd9f623..114d7e9e9 100644 --- a/evap/student/forms.py +++ b/evap/student/forms.py @@ -80,7 +80,8 @@ def __init__(self, *args, contribution, questionnaire, **kwargs): field = TextAnswerField.from_question(question) elif question.is_rating_question: field = RatingAnswerField.from_question(question) - elif question.is_heading_question: + else: + assert question.is_heading_question field = HeadingField.from_question(question) identifier = answer_field_id(contribution, questionnaire, question) diff --git a/evap/student/templates/student_global_reward.html b/evap/student/templates/student_global_reward.html new file mode 100644 index 000000000..91ff92884 --- /dev/null +++ b/evap/student/templates/student_global_reward.html @@ -0,0 +1,53 @@ +{% load evaluation_filters %} + +{% if global_rewards %} +
+
+ {% translate "Fundraising" %} + {% if global_rewards.last_vote_datetime %} +
+ {% blocktranslate trimmed with time_interval=global_rewards.last_vote_datetime|timesince %} + Last evaluation: {{ time_interval }} ago + {% endblocktranslate %} +
+ {% endif %} +
+
+
+ + + + + {% blocktranslate trimmed count votes=global_rewards.vote_count with percent=global_rewards.vote_count|percentage_zero_on_error:global_rewards.participation_count %} + {{ votes }} submitted evaluation ({{ percent }}) + {% plural %} + {{ votes }} submitted evaluations ({{ percent }}) + {% endblocktranslate %} + + + + {% for reward in global_rewards.rewards_with_progress %} +
+
+
+ {{ reward.vote_ratio|percentage:1 }} +
+
{{ reward.text }}
+
+ {% endfor %} +
+ +
+
+{% endif %} diff --git a/evap/student/templates/student_index.html b/evap/student/templates/student_index.html index beef87904..c31426427 100644 --- a/evap/student/templates/student_index.html +++ b/evap/student/templates/student_index.html @@ -17,6 +17,8 @@ {% endif %} + {% include 'student_global_reward.html' with global_rewards=global_rewards %} + {% if unfinished_evaluations %}
diff --git a/evap/student/tests/test_views.py b/evap/student/tests/test_views.py index c1960ad58..c9e495de4 100644 --- a/evap/student/tests/test_views.py +++ b/evap/student/tests/test_views.py @@ -1,4 +1,5 @@ import datetime +from fractions import Fraction from functools import partial from django.test.utils import override_settings @@ -30,7 +31,8 @@ class TestStudentIndexView(WebTestWith200Check): def setUpTestData(cls): # View is only visible to users participating in at least one evaluation. cls.user = baker.make(UserProfile, email="student@institution.example.com") - baker.make(Evaluation, participants=[cls.user]) + cls.semester = baker.make(Semester, is_active=True) + cls.evaluation = baker.make(Evaluation, course__semester=cls.semester, participants=[cls.user]) cls.test_users = [cls.user] @@ -52,6 +54,95 @@ def test_num_queries_is_constant(self): with self.assertNumQueries(FuzzyInt(0, 100)): self.app.get(self.url, user=self.user) + @override_settings( + GLOBAL_EVALUATION_PROGRESS_REWARDS=[(Fraction(1, 10), "a dog"), (Fraction(5, 10), "a quokka")], + GLOBAL_EVALUATION_PROGRESS_INFO_TEXT={"de": "info_text_str", "en": "info_text_str"}, + GLOBAL_EVALUATION_PROGRESS_EXCLUDED_COURSE_TYPE_IDS=[1042], + GLOBAL_EVALUATION_PROGRESS_EXCLUDED_EVALUATION_IDS=[1043], + ) + def test_global_reward_progress(self): + excluded_states = [state for state in Evaluation.State if state < Evaluation.State.APPROVED] + included_states = [state for state in Evaluation.State if state >= Evaluation.State.APPROVED] + + users = baker.make(UserProfile, _quantity=20, _bulk_create=True) + make_evaluation = partial( + baker.make, + Evaluation, + course__semester=self.semester, + participants=users, + voters=users[:10], + state=Evaluation.State.APPROVED, + ) + + # excluded + make_evaluation(is_rewarded=False) + make_evaluation(is_single_result=True) + make_evaluation(course__is_private=True) + make_evaluation(id=1043) + make_evaluation(course__type__id=1042) + make_evaluation(_quantity=len(excluded_states), state=iter(excluded_states)) + + # included + included_evaluations = [ + *make_evaluation(_quantity=len(included_states), state=iter(included_states)), + make_evaluation(_voter_count=123, _participant_count=456), + ] + + baker.make(VoteTimestamp, evaluation=included_evaluations[0]) + + expected_participants = sum(e.num_participants for e in included_evaluations) + expected_voters = sum(e.num_voters for e in included_evaluations) + expected_voter_percent = 100 * expected_voters // expected_participants + + page = self.app.get(self.url, user=self.user) + self.assertIn("Fundraising", page) + self.assertIn("info_text_str", page) + self.assertIn("Last evaluation:", page) + self.assertIn(f"{expected_voters} submitted evaluations ({expected_voter_percent}%)", page) + self.assertIn("a quokka", page) + self.assertIn("10%", page) + self.assertIn("a dog", page) + self.assertIn("50%", page) + + @override_settings(GLOBAL_EVALUATION_PROGRESS_REWARDS=[(Fraction("0.07"), "a dog")]) + def test_global_reward_progress_edge_cases(self): + # no active semester + Semester.objects.update(is_active=False) + page = self.app.get(self.url, user=self.user) + self.assertNotIn("7%", page) + self.assertNotIn("a dog", page) + + # no voters / participants -> possibly zero division + # also: no last vote timestamp + semester = baker.make(Semester, is_active=True) + page = self.app.get(self.url, user=self.user) + self.assertNotIn("Last evaluation:", page) + self.assertIn("0 submitted evaluations (0%)", page) + self.assertIn("7%", page) + self.assertIn("a dog", page) + + # more voters than required for last reward + baker.make( + Evaluation, + course__semester=semester, + _voter_count=89, + _participant_count=97, + state=Evaluation.State.EVALUATED, + ) + page = self.app.get(self.url, user=self.user) + self.assertIn("89 submitted evaluations (91%)", page) # 91% is intentionally rounded down + self.assertIn("7%", page) + self.assertIn("a dog", page) + + @override_settings( + GLOBAL_EVALUATION_PROGRESS_REWARDS=[], + GLOBAL_EVALUATION_PROGRESS_INFO_TEXT={"de": "info_text_str", "en": "info_text_str"}, + ) + def test_global_reward_progress_hidden(self): + page = self.app.get(self.url, user=self.user) + self.assertNotIn("Fundraising", page) + self.assertNotIn("info_text_str", page) + @override_settings(INSTITUTION_EMAIL_DOMAINS=["example.com"]) class TestVoteView(WebTest): diff --git a/evap/student/views.py b/evap/student/views.py index b9a82296c..1a85c1c59 100644 --- a/evap/student/views.py +++ b/evap/student/views.py @@ -1,13 +1,18 @@ +import datetime +import math from collections import OrderedDict +from dataclasses import dataclass +from fractions import Fraction from django.conf import settings from django.contrib import messages from django.core.exceptions import PermissionDenied from django.db import transaction -from django.db.models import Exists, F, OuterRef, Q +from django.db.models import Exists, F, Max, OuterRef, Q, Sum from django.http import HttpResponse from django.shortcuts import get_object_or_404, render from django.urls import reverse +from django.utils.translation import get_language from django.utils.translation import gettext as _ from evap.evaluation.auth import participant_required @@ -24,6 +29,69 @@ SUCCESS_MAGIC_STRING = "vote submitted successfully" +@dataclass +class GlobalRewards: + @dataclass + class RewardProgress: + progress: Fraction # progress towards this reward, relative to max reward, between 0 and 1 + vote_ratio: Fraction + text: str + + vote_count: int + participation_count: int + max_reward_votes: int + bar_width_votes: int + last_vote_datetime: datetime.datetime + rewards_with_progress: list[RewardProgress] + info_text: str + + @staticmethod + def from_settings() -> "GlobalRewards | None": + if not settings.GLOBAL_EVALUATION_PROGRESS_REWARDS: + return None + + if not Semester.active_semester(): + return None + + evaluations = ( + Semester.active_semester() + .evaluations.filter(is_single_result=False) + .exclude(state__lt=Evaluation.State.APPROVED) + .exclude(is_rewarded=False) + .exclude(id__in=settings.GLOBAL_EVALUATION_PROGRESS_EXCLUDED_EVALUATION_IDS) + .exclude(course__type__id__in=settings.GLOBAL_EVALUATION_PROGRESS_EXCLUDED_COURSE_TYPE_IDS) + .exclude(course__is_private=True) + ) + + vote_count, participation_count = ( + Evaluation.annotate_with_participant_and_voter_counts(evaluations) + .aggregate(Sum("num_voters", default=0), Sum("num_participants", default=0)) + .values() + ) + + max_reward_vote_ratio, __ = max(settings.GLOBAL_EVALUATION_PROGRESS_REWARDS) + max_reward_votes = math.ceil(max_reward_vote_ratio * participation_count) + + rewards_with_progress = [ + GlobalRewards.RewardProgress(progress=vote_ratio / max_reward_vote_ratio, vote_ratio=vote_ratio, text=text) + for vote_ratio, text in settings.GLOBAL_EVALUATION_PROGRESS_REWARDS + ] + + last_vote_datetime = VoteTimestamp.objects.filter(evaluation__in=evaluations).aggregate(Max("timestamp"))[ + "timestamp__max" + ] + + return GlobalRewards( + vote_count=vote_count, + participation_count=participation_count, + max_reward_votes=max_reward_votes, + bar_width_votes=min(vote_count, max_reward_votes), + last_vote_datetime=last_vote_datetime, + rewards_with_progress=rewards_with_progress, + info_text=settings.GLOBAL_EVALUATION_PROGRESS_INFO_TEXT[get_language()], + ) + + @participant_required def index(request): query = ( @@ -111,6 +179,7 @@ def sorter(evaluation): "can_download_grades": request.user.can_download_grades, "unfinished_evaluations": unfinished_evaluations, "evaluation_end_warning_period": settings.EVALUATION_END_WARNING_PERIOD, + "global_rewards": GlobalRewards.from_settings(), } return render(request, "student_index.html", template_data) @@ -186,8 +255,8 @@ def render_vote_page(request, evaluation, preview, for_rendering_in_modal=False) @participant_required -def vote(request, evaluation_id): - # pylint: disable=too-many-nested-blocks,too-many-branches +def vote(request, evaluation_id): # noqa: PLR0912 + # pylint: disable=too-many-nested-blocks evaluation = get_object_or_404(Evaluation, id=evaluation_id) if not evaluation.can_be_voted_for_by(request.user): raise PermissionDenied diff --git a/package-lock.json b/package-lock.json index f2e64e4bc..62b35bba5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "puppeteer": "^21.0.1", "sass": "1.77.1", "ts-jest": "^29.1.0", - "typescript": "^5.4.2" + "typescript": "^5.5.2" } }, "node_modules/@ampproject/remapping": { @@ -5582,9 +5582,9 @@ } }, "node_modules/typescript": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", - "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.2.tgz", + "integrity": "sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==", "dev": true, "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index f93b51fcc..31698447a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "puppeteer": "^21.0.1", "sass": "1.77.1", "ts-jest": "^29.1.0", - "typescript": "^5.4.2" + "typescript": "^5.5.2" }, "jest": { "testRunner": "jest-jasmine2", diff --git a/pyproject.toml b/pyproject.toml index 9654ad6ff..dae30b59b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,10 +38,8 @@ ignore = [ "N802", # not as mighty as pylint's invalid-name https://github.com/astral-sh/ruff/issues/7660 "PLR0913", # we can't determine a good limit for arguments. reviews should spot bad cases of this. "PLR2004", "PLW2901", - "PLR0912", # sevaral ruff bugs: https://www.github.com/astral-sh/ruff/issues/11313, https://www.github.com/astral-sh/ruff/issues/11205 ] -ignore-init-module-imports = true pep8-naming.extend-ignore-names = ["assert*", "*Formset"] # custom assert methods use camelCase; Formsets use PascalCase [tool.ruff.lint.per-file-ignores] @@ -98,7 +96,7 @@ disable = [ "use-implicit-booleaness-not-comparison-to-string", # forces us to use less expressive code "use-implicit-booleaness-not-comparison-to-zero", # forces us to use less expressive code # the following are covered by ruff - "broad-exception-caught", "line-too-long","unused-wildcard-import", "wildcard-import","too-many-arguments", "too-many-statements", "too-many-return-statements" + "broad-exception-caught", "line-too-long", "unused-wildcard-import", "wildcard-import", "too-many-arguments", "too-many-statements", "too-many-return-statements", "too-many-branches", ] ############################################## @@ -117,7 +115,6 @@ omit = ["*/migrations/*", "*__init__.py"] packages = ["evap", "tools"] plugins = ["mypy_django_plugin.main"] exclude = 'evap/.*/migrations/.*\.py$' -no_implicit_optional = true [tool.django-stubs] django_settings_module = "evap.settings" diff --git a/requirements-dev.txt b/requirements-dev.txt index 03c3146d0..9ca775dd5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -9,8 +9,8 @@ model-bakery~=1.18.0 mypy~=1.10.0 openpyxl-stubs~=0.1.25 pylint-django~=2.5.4 -pylint~=3.1.0 -ruff==0.4.8 +pylint~=3.2.3 +ruff==0.4.10 tblib~=3.0.0 xlrd~=2.0.1 typeguard~=4.3.0 diff --git a/requirements.txt b/requirements.txt index 5433752d9..17a412215 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ django-extensions==3.2.3 django-fsm==2.8.2 django~=5.0 mozilla-django-oidc==4.0.1 -openpyxl==3.1.2 +openpyxl==3.1.5 psycopg2-binary==2.9.9 -redis==5.0.5 +redis==5.0.7 xlwt==1.3.0