diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..ed4e53f5b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Since text files will be used in a linux environment where LF is expected, git shouldn't change line endings to CRLF on windows machines +* text=auto eol=lf + +# things that fail without this: +# * bash autocompletion (.sh): -bash: /etc/bash_completion.d/manage_autocompletion.sh: line 8: syntax error near unexpected token `$'\r'' +# * running python files: /usr/bin/env: ‘python3\r’: No such file or directory +# Since that's a huge part of the code base, it doesn't really make sense to allow automatic EOL conversion for the rest of the files. diff --git a/.gitmodules b/.gitmodules index c0fdc4ec1..54477b591 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "evap/static/bootstrap"] path = evap/static/bootstrap url = https://github.com/twbs/bootstrap.git + shallow = true diff --git a/README.md b/README.md index 9059163a1..8042e6d76 100644 --- a/README.md +++ b/README.md @@ -12,33 +12,31 @@ EvaP is the course evaluation system used internally at Hasso Plattner Institute For the documentation, please see our [wiki](https://github.com/e-valuation/EvaP/wiki). -## Installation +## Installation (for Development) The easiest setup using [Vagrant](https://www.vagrantup.com) is shown here. -0. Install [git](https://git-scm.com/downloads), [Vagrant](https://www.vagrantup.com/downloads.html), and one of [VirtualBox](https://www.virtualbox.org/wiki/Downloads) (recommended) or [Docker](https://docs.docker.com/engine/install/) (for ARM systems). +1. Install [git](https://git-scm.com/downloads), [Vagrant](https://www.vagrantup.com/downloads.html), and one of [VirtualBox](https://www.virtualbox.org/wiki/Downloads) (recommended) or [Docker](https://docs.docker.com/engine/install/) (for ARM systems). -1. Fork the EvaP repository (using the Fork-button in the upper right corner on GitHub). +2. Run the following commands on the command line to clone the repository, create the Vagrant VM and run the Django development server. + * If you are familiar with the fork-based open source workflow, create a fork and clone that (using SSH if you prefer that). -2. Windows users only (might not apply for the Linux subsystem): - * Line endings: git's [`core.autocrlf` setting](https://git-scm.com/book/en/v2/Customizing-Git-Git-Configuration#_core_autocrlf) has to be `false` or `input` so git does not convert line endings on checkout, because the code will be used in a Linux VM. We suggest using this command in Git Bash: + * Windows users: We have observed [weird](https://www.github.com/git-for-windows/git/issues/4705) [behavior](https://www.github.com/git-for-windows/git/issues/4704) with SSH in Git Bash on Windows and thus recommend using PowerShell instead. - ```bash - git config --global core.autocrlf input - ``` + * To use Docker, replace `vagrant up` with `vagrant up --provider docker && vagrant provision`. -3. Run the following commands on the command line to clone the repository, create the Vagrant VM and run the Django development server. - To use Docker, replace `vagrant up` with `vagrant up --provider docker && vagrant provision`. ```bash - git clone --recurse-submodules https://github.com//EvaP.git + git clone --recurse-submodules https://github.com/e-valuation/EvaP.git cd EvaP vagrant up vagrant ssh + ``` + and, after the last command opened an SSH session in the development machine: + ```bash ./manage.py run ``` -4. Open your browser at http://localhost:8000/ and login with email `evap@institution.example.com` and password `evap`. - +3. Open your browser at http://localhost:8000/ and login with email `evap@institution.example.com` and password `evap`. That's it! @@ -55,6 +53,22 @@ or, to combine all three, simply run `./manage.py precommit`. You can also set up `pylint`, `isort`, `black` and `prettier` in your IDE to avoid doing this manually all the time. +### Creating a Pull Request (Workflow Suggestion) +1. (once) [Fork](https://github.com/e-valuation/EvaP/fork) the repository so you have a GitHub repo that you have write access to. + +2. (once) Set up some authentication for GitHub that allows push access. A common option is using [SSH keys](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/about-ssh), the remaining instructions assume an SSH key setup. An alternative is using the [GitHub CLI tool](https://cli.github.com/). + +3. (once) Ensure your [git remotes](https://git-scm.com/book/en/v2/Git-Basics-Working-with-Remotes) are setup to use SSH. To fetch the up-to-date state of the official repo, it's useful to have an "upstream" remote configured: + ```bash + git remote set-url origin git@github.com:/EvaP.git + git remote add upstream git@github.com:e-valuation/EvaP.git + ``` + +4. Create a branch (`git switch -c `), commit your changes (`git add` and `git commit`), and push them (`git push`). "Push" will ask you to specify an upstream branch (`git push -u origin `). + +5. GitHub should now ask you whether you want to open a pull request ("PR"). If the PR solves an issue, use one of GitHub's [magic keywords](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) (like "fixes") in the pull request description to create a link between your PR and the issue. If necessary, please also provide a short summary of your changes in the description. + + ## License MIT, see [LICENSE.md](LICENSE.md). diff --git a/Vagrantfile b/Vagrantfile index c2afd0b3e..071c43538 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -18,6 +18,12 @@ Vagrant.configure("2") do |config| end end + if Vagrant::Util::Platform.windows? then + # workaround for git bash not automatically allocating a tty on windows in some scenarios + # see https://github.com/hashicorp/vagrant/issues/9143#issuecomment-401088752 + config.ssh.extra_args = "-tt" + end + config.vm.provider :docker do |d, override| d.image = "ubuntu:jammy" # Docker container really are supposed to be used differently. Hacky way to make it into a "VM". diff --git a/evap/context_processors.py b/evap/context_processors.py index f35071edb..e09f01e8c 100644 --- a/evap/context_processors.py +++ b/evap/context_processors.py @@ -3,6 +3,8 @@ from django.conf import settings from django.utils.translation import get_language +from evap.evaluation.forms import NotebookForm + def slogan(request): if get_language() == "de": @@ -14,5 +16,11 @@ def debug(request): return {"debug": settings.DEBUG} +def notebook_form(request): + if request.user.is_authenticated: + return {"notebook_form": NotebookForm(instance=request.user)} + return {} + + def allow_anonymous_feedback_messages(request): return {"allow_anonymous_feedback_messages": settings.ALLOW_ANONYMOUS_FEEDBACK_MESSAGES} diff --git a/evap/contributor/templates/contributor_evaluation_form.html b/evap/contributor/templates/contributor_evaluation_form.html index 5f8376851..ecd2e22c5 100644 --- a/evap/contributor/templates/contributor_evaluation_form.html +++ b/evap/contributor/templates/contributor_evaluation_form.html @@ -127,8 +127,14 @@ {% include 'confirmation_modal.html' with modal_id='approveEvaluationModal' title=title question=question action_text=action_text btn_type='primary' %} diff --git a/evap/contributor/templates/contributor_index.html b/evap/contributor/templates/contributor_index.html index e3d8253eb..e61132d2c 100644 --- a/evap/contributor/templates/contributor_index.html +++ b/evap/contributor/templates/contributor_index.html @@ -228,14 +228,15 @@

if(type == "q"){ // open collapsed answer and scroll into center - const answer_div = $("#faq-"+id+"-a"); window.history.pushState('', id, '/faq#faq-' + id + '-q'); var answerCard = document.getElementById("faq-"+id+"-a"); var answerCardCollapse = bootstrap.Collapse.getOrCreateInstance(answerCard); diff --git a/evap/evaluation/templates/index.html b/evap/evaluation/templates/index.html index 1e75d7a37..a740729be 100644 --- a/evap/evaluation/templates/index.html +++ b/evap/evaluation/templates/index.html @@ -14,7 +14,7 @@

{% if openid_active %}

- {% trans 'Log in using HPI OpenID Connect.' %} + {% trans 'Log in using Keycloak.' %}

diff --git a/evap/evaluation/templates/navbar.html b/evap/evaluation/templates/navbar.html index 17e653d95..5f6a7c49f 100644 --- a/evap/evaluation/templates/navbar.html +++ b/evap/evaluation/templates/navbar.html @@ -92,7 +92,7 @@ {% csrf_token %} @@ -105,13 +105,13 @@
{% csrf_token %} -
{% csrf_token %} -
diff --git a/evap/evaluation/templates/notebook.html b/evap/evaluation/templates/notebook.html new file mode 100644 index 000000000..20dc33ec7 --- /dev/null +++ b/evap/evaluation/templates/notebook.html @@ -0,0 +1,29 @@ +
+ +
diff --git a/evap/evaluation/templates/sortable_form_js.html b/evap/evaluation/templates/sortable_form_js.html deleted file mode 100644 index 3cf10df3f..000000000 --- a/evap/evaluation/templates/sortable_form_js.html +++ /dev/null @@ -1,62 +0,0 @@ - diff --git a/evap/evaluation/templatetags/evaluation_filters.py b/evap/evaluation/templatetags/evaluation_filters.py index c9dbabb67..bc6b7dabf 100644 --- a/evap/evaluation/templatetags/evaluation_filters.py +++ b/evap/evaluation/templatetags/evaluation_filters.py @@ -221,3 +221,10 @@ def order_by(iterable, attribute): @register.filter def get(dictionary, key): return dictionary.get(key) + + +@register.filter +def add_class(widget, class_name_to_add: str): + new_class = class_name_to_add + " " + widget["attrs"]["class"] if "class" in widget["attrs"] else class_name_to_add + widget["attrs"].update({"class": new_class}) + return widget diff --git a/evap/evaluation/tests/test_auth.py b/evap/evaluation/tests/test_auth.py index a3cf03d4c..899b6e475 100644 --- a/evap/evaluation/tests/test_auth.py +++ b/evap/evaluation/tests/test_auth.py @@ -4,11 +4,15 @@ from django.conf import settings from django.contrib.auth.models import Group from django.core import mail +from django.core.exceptions import PermissionDenied +from django.http import HttpRequest, HttpResponse from django.test import override_settings from django.urls import reverse +from django.views import View from model_bakery import baker from evap.evaluation import auth +from evap.evaluation.auth import class_or_function_check_decorator from evap.evaluation.models import Contribution, Evaluation, UserProfile from evap.evaluation.tests.tools import WebTest @@ -176,3 +180,51 @@ def test_entering_staff_mode_after_logout_and_login(self): page = page.forms["enter-staff-mode-form"].submit().follow().follow() self.assertTrue("staff_mode_start_time" in self.app.session) self.assertContains(page, "Users") + + +class TestAuthDecorators(WebTest): + @classmethod + def setUpTestData(cls): + @class_or_function_check_decorator + def check_decorator(user: UserProfile) -> bool: + return getattr(user, "some_condition") # mocked later + + @check_decorator + def function_based_view(_request): + return HttpResponse() + + @check_decorator + class ClassBasedView(View): + def get(self, _request): + return HttpResponse() + + cls.user = baker.make(UserProfile, email="testuser@institution.example.com") + cls.function_based_view = function_based_view + cls.class_based_view = ClassBasedView.as_view() + + @classmethod + def make_request(cls): + request = HttpRequest() + request.method = "GET" + request.user = cls.user + return request + + @patch("evap.evaluation.models.UserProfile.some_condition", True, create=True) + def test_passing_user_function_based(self): + response = self.function_based_view(self.make_request()) # pylint: disable=too-many-function-args + self.assertEqual(response.status_code, 200) + + @patch("evap.evaluation.models.UserProfile.some_condition", True, create=True) + def test_passing_user_class_based(self): + response = self.class_based_view(self.make_request()) + self.assertEqual(response.status_code, 200) + + @patch("evap.evaluation.models.UserProfile.some_condition", False, create=True) + def test_failing_user_function_based(self): + with self.assertRaises(PermissionDenied): + self.function_based_view(self.make_request()) # pylint: disable=too-many-function-args + + @patch("evap.evaluation.models.UserProfile.some_condition", False, create=True) + def test_failing_user_class_based(self): + with self.assertRaises(PermissionDenied): + self.class_based_view(self.make_request()) diff --git a/evap/evaluation/tests/test_models.py b/evap/evaluation/tests/test_models.py index f489b8573..fcb9839bf 100644 --- a/evap/evaluation/tests/test_models.py +++ b/evap/evaluation/tests/test_models.py @@ -15,7 +15,7 @@ CourseType, EmailTemplate, Evaluation, - NotArchiveable, + NotArchivableError, Question, Questionnaire, QuestionType, @@ -748,9 +748,9 @@ def test_archiving_participations_does_not_change_results(self): def test_archiving_participations_twice_raises_exception(self): self.semester.archive() - with self.assertRaises(NotArchiveable): + with self.assertRaises(NotArchivableError): self.semester.archive() - with self.assertRaises(NotArchiveable): + with self.assertRaises(NotArchivableError): self.semester.courses.first().evaluations.first()._archive() def test_evaluation_participations_are_not_archived_if_participant_count_is_set(self): diff --git a/evap/evaluation/tests/test_models_logging.py b/evap/evaluation/tests/test_models_logging.py index b1a67dd14..ea900dc4f 100644 --- a/evap/evaluation/tests/test_models_logging.py +++ b/evap/evaluation/tests/test_models_logging.py @@ -5,7 +5,7 @@ from model_bakery import baker from evap.evaluation.models import Contribution, Course, Evaluation, Questionnaire, UserProfile -from evap.evaluation.models_logging import FieldAction +from evap.evaluation.models_logging import FieldAction, InstanceActionType class TestLoggedModel(TestCase): @@ -52,7 +52,10 @@ def test_data_attribute_is_correctly_parsed_to_fieldactions(self): ) def test_deletion_data(self): - self.assertEqual(self.evaluation._get_change_data(action_type="delete")["course"]["delete"][0], self.course.id) + self.assertEqual( + self.evaluation._get_change_data(action_type=InstanceActionType.DELETE)["course"]["delete"][0], + self.course.id, + ) self.evaluation.delete() self.assertEqual(self.evaluation.related_logentries().count(), 0) diff --git a/evap/evaluation/tests/test_tools.py b/evap/evaluation/tests/test_tools.py index ee5adfa76..ce8a9e5c7 100644 --- a/evap/evaluation/tests/test_tools.py +++ b/evap/evaluation/tests/test_tools.py @@ -40,12 +40,12 @@ def test_respects_stored_language(self): translation.activate("en") # for following tests -class SaboteurException(Exception): +class SaboteurError(Exception): """An exception class used for making sure that our mock is raising the exception and not some other unrelated code""" class TestLogExceptionsDecorator(TestCase): - @patch("evap.evaluation.models.Evaluation.update_evaluations", side_effect=SaboteurException()) + @patch("evap.evaluation.models.Evaluation.update_evaluations", side_effect=SaboteurError()) @patch("evap.evaluation.management.commands.tools.logger.exception") def test_log_exceptions_decorator(self, mock_logger, __): """ @@ -54,7 +54,7 @@ def test_log_exceptions_decorator(self, mock_logger, __): One could create a mock management command and call its handle method manually, but to me it seemed safer to use a real one. """ - with self.assertRaises(SaboteurException): + with self.assertRaises(SaboteurError): management.call_command("update_evaluation_states") self.assertTrue(mock_logger.called) diff --git a/evap/evaluation/tests/test_views.py b/evap/evaluation/tests/test_views.py index bac125f1e..2af1ac6d2 100644 --- a/evap/evaluation/tests/test_views.py +++ b/evap/evaluation/tests/test_views.py @@ -8,7 +8,20 @@ from model_bakery import baker from evap.evaluation.models import Evaluation, Question, QuestionType, UserProfile -from evap.evaluation.tests.tools import WebTestWith200Check, create_evaluation_with_responsible_and_editor +from evap.evaluation.tests.tools import ( + WebTestWith200Check, + create_evaluation_with_responsible_and_editor, + store_ts_test_asset, +) + + +class RenderJsTranslationCatalog(WebTest): + url = reverse("javascript-catalog") + + def render_pages(self): + # Not using render_pages decorator to manually create a single (special) javascript file + content = self.app.get(self.url).content + store_ts_test_asset("catalog.js", content) @override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) @@ -161,7 +174,7 @@ def test_changes_language(self): class TestProfileView(WebTest): - url = "/profile" + url = reverse("evaluation:profile_edit") @classmethod def setUpTestData(cls): @@ -229,3 +242,19 @@ def test_answer_ordering(self): page = self.app.get(self.url, user=self.voting_user, status=200).body.decode() self.assertLess(page.index("Strongly
disagree"), page.index("Strongly
agree")) self.assertIn("The answer scale is inverted for this question", page) + + +class TestNotebookView(WebTest): + url = reverse("evaluation:profile_edit") # is used exemplarily, notebook is accessed from all pages + note = "Data is so beautiful" + + def test_notebook(self): + user = baker.make(UserProfile, email="student@institution.example.com") + + page = self.app.get(self.url, user=user) + form = page.forms["notebook-form"] + form["notes"] = self.note + form.submit() + + user.refresh_from_db() + self.assertEqual(user.notes, self.note) diff --git a/evap/evaluation/tests/tools.py b/evap/evaluation/tests/tools.py index b44ff1224..55a60127e 100644 --- a/evap/evaluation/tests/tools.py +++ b/evap/evaluation/tests/tools.py @@ -1,11 +1,14 @@ import functools import os from collections.abc import Sequence +from contextlib import contextmanager from datetime import timedelta from django.conf import settings from django.contrib.auth.models import Group +from django.db import DEFAULT_DB_ALIAS, connections from django.http.request import QueryDict +from django.test.utils import CaptureQueriesContext from django.utils import timezone from django_webtest import WebTest from model_bakery import baker @@ -83,6 +86,15 @@ def let_user_vote_for_evaluation(user, evaluation, create_answers=False): RatingAnswerCounter.objects.bulk_update(rac_by_contribution_question.values(), ["count"]) +def store_ts_test_asset(relative_path: str, content) -> None: + absolute_path = os.path.join(settings.STATICFILES_DIRS[0], "ts", "rendered", relative_path) + + os.makedirs(os.path.dirname(absolute_path), exist_ok=True) + + with open(absolute_path, "wb") as file: + file.write(content) + + def render_pages(test_item): """Decorator which annotates test methods which render pages. The containing class is expected to include a `url` attribute which matches a valid path. @@ -91,19 +103,15 @@ def render_pages(test_item): The value is a byte string of the page content.""" @functools.wraps(test_item) - def decorator(self): + def decorator(self) -> None: pages = test_item(self) - static_directory = settings.STATICFILES_DIRS[0] - url = getattr(self, "render_pages_url", self.url) - # Remove the leading slash from the url to prevent that an absolute path is created - directory = os.path.join(static_directory, "ts", "rendered", url[1:]) - os.makedirs(directory, exist_ok=True) for name, content in pages.items(): - with open(os.path.join(directory, f"{name}.html"), "wb") as html_file: - html_file.write(content) + # Remove the leading slash from the url to prevent that an absolute path is created + path = os.path.join(url[1:], f"{name}.html") + store_ts_test_asset(path, content) return decorator @@ -211,3 +219,29 @@ def make_rating_answer_counters( RatingAnswerCounter.objects.bulk_create(counters) return counters + + +@contextmanager +def assert_no_database_modifications(*args, **kwargs): + assert len(connections.all()) == 1, "Found more than one connection, so the decorator might monitor the wrong one" + + # may be extended with other non-modifying verbs + allowed_prefixes = ["select", "savepoint", "release savepoint"] + + conn = connections[DEFAULT_DB_ALIAS] + with CaptureQueriesContext(conn): + yield + + for query in conn.queries_log: + if ( + query["sql"].startswith('INSERT INTO "testing_cache_sessions"') + or query["sql"].startswith('UPDATE "testing_cache_sessions"') + or query["sql"].startswith('DELETE FROM "testing_cache_sessions"') + ): + # These queries are caused by interacting with the test-app (self.app.get()), since that opens a session. + # That's not what we want to test for here + continue + + lower_sql = query["sql"].lower() + if not any(lower_sql.startswith(prefix) for prefix in allowed_prefixes): + raise AssertionError("Unexpected modifying query found: " + query["sql"]) diff --git a/evap/evaluation/tools.py b/evap/evaluation/tools.py index dac303a99..ad9aa51a7 100644 --- a/evap/evaluation/tools.py +++ b/evap/evaluation/tools.py @@ -1,22 +1,29 @@ import datetime +import typing from abc import ABC, abstractmethod from collections import defaultdict from collections.abc import Iterable, Mapping -from typing import Any, TypeVar +from typing import Any, Protocol, TypeVar from urllib.parse import quote import xlwt from django.conf import settings from django.core.exceptions import SuspiciousOperation, ValidationError from django.db.models import Model -from django.http import HttpResponse +from django.forms.formsets import BaseFormSet +from django.http import HttpRequest, HttpResponse from django.shortcuts import get_object_or_404 +from django.utils.datastructures import MultiValueDict from django.utils.translation import get_language +from django.views.generic import FormView +from django_stubs_ext import StrOrPromise M = TypeVar("M", bound=Model) T = TypeVar("T") Key = TypeVar("Key") Value = TypeVar("Value") +CellValue = str | int | float | None +CV = TypeVar("CV", bound=CellValue) def unordered_groupby(key_value_pairs: Iterable[tuple[Key, Value]]) -> dict[Key, list[Value]]: @@ -33,7 +40,9 @@ def unordered_groupby(key_value_pairs: Iterable[tuple[Key, Value]]) -> dict[Key, return dict(result) -def get_object_from_dict_pk_entry_or_logged_40x(model_cls: type[M], dict_obj: Mapping[str, Any], key: str) -> M: +def get_object_from_dict_pk_entry_or_logged_40x( + model_cls: type[M], dict_obj: MultiValueDict[str, Any] | Mapping[str, Any], key: str +) -> M: try: return get_object_or_404(model_cls, pk=dict_obj[key]) # ValidationError happens for UUID id fields when passing invalid arguments @@ -41,7 +50,7 @@ def get_object_from_dict_pk_entry_or_logged_40x(model_cls: type[M], dict_obj: Ma raise SuspiciousOperation from e -def is_prefetched(instance, attribute_name: str): +def is_prefetched(instance, attribute_name: str) -> bool: """ Is the given related attribute prefetched? Can be used to do ordering or counting in python and avoid additional database queries @@ -57,7 +66,7 @@ def is_prefetched(instance, attribute_name: str): return False -def discard_cached_related_objects(instance): +def discard_cached_related_objects(instance: M) -> M: """ Discard all cached related objects (for ForeignKey and M2M Fields). Useful if there were changes, but django's caching would still give us the old @@ -65,44 +74,44 @@ def discard_cached_related_objects(instance): hierarchy (e.g. for storing instances in a cache) """ # Extracted from django's refresh_from_db, which sadly doesn't offer this part alone (without hitting the DB). - for field in instance._meta.concrete_fields: + for field in instance._meta.concrete_fields: # type: ignore if field.is_relation and field.is_cached(instance): field.delete_cached_value(instance) - for field in instance._meta.related_objects: + for field in instance._meta.related_objects: # type: ignore if field.is_cached(instance): field.delete_cached_value(instance) - instance._prefetched_objects_cache = {} + instance._prefetched_objects_cache = {} # type: ignore return instance -def is_external_email(email): +def is_external_email(email: str) -> bool: return not any(email.endswith("@" + domain) for domain in settings.INSTITUTION_EMAIL_DOMAINS) -def sort_formset(request, formset): +def sort_formset(request: HttpRequest, formset: BaseFormSet) -> None: if request.POST: # if not, there will be no cleaned_data and the models should already be sorted anyways formset.is_valid() # make sure all forms have cleaned_data formset.forms.sort(key=lambda f: f.cleaned_data.get("order", 9001)) -def date_to_datetime(date): +def date_to_datetime(date: datetime.date) -> datetime.datetime: return datetime.datetime(year=date.year, month=date.month, day=date.day) -def vote_end_datetime(vote_end_date): +def vote_end_datetime(vote_end_date: datetime.date) -> datetime.datetime: # The evaluation actually ends at EVALUATION_END_OFFSET_HOURS:00 of the day AFTER self.vote_end_date. return date_to_datetime(vote_end_date) + datetime.timedelta(hours=24 + settings.EVALUATION_END_OFFSET_HOURS) -def get_parameter_from_url_or_session(request, parameter, default=False): - result = request.GET.get(parameter, None) - if result is None: # if no parameter is given take session value +def get_parameter_from_url_or_session(request: HttpRequest, parameter: str, default=False) -> bool: + result_str = request.GET.get(parameter, None) + if result_str is None: # if no parameter is given take session value result = request.session.get(parameter, default) else: - result = {"true": True, "false": False}.get(result.lower()) # convert parameter to boolean + result = {"true": True, "false": False}.get(result_str.lower()) # convert parameter to boolean request.session[parameter] = result # store value for session return result @@ -114,7 +123,10 @@ def translate(**kwargs): return property(lambda self: getattr(self, kwargs[get_language() or "en"])) -def clean_email(email): +EmailT = TypeVar("EmailT", str, None) + + +def clean_email(email: EmailT) -> EmailT: if email: email = email.strip().lower() # Replace email domains in case there are multiple alias domains used in the organisation and all emails should @@ -125,11 +137,12 @@ def clean_email(email): return email -def capitalize_first(string): +def capitalize_first(string: StrOrPromise) -> str: + """Realize lazy promise objects and capitalize first letter.""" return string[0].upper() + string[1:] -def ilen(iterable): +def ilen(iterable: Iterable) -> int: return sum(1 for _ in iterable) @@ -138,6 +151,56 @@ def assert_not_none(value: T | None) -> T: return value +class FormsetView(FormView): + """ + Just like `FormView`, but with a renaming from "form" to "formset". + """ + + @property + def form_class(self): + return self.formset_class + + def get_context_data(self, **kwargs) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + context["formset"] = context.pop("form") + return context + + # As an example for the logic, consider the following: Django calls `get_form_kwargs`, which we delegate to + # `get_formset_kwargs`. Users can thus override `get_formset_kwargs` instead. If it is not overridden, we delegate + # to the original `get_form_kwargs` instead. The same approach is used for the other renamed methods. + + def get_form_kwargs(self) -> dict: + return self.get_formset_kwargs() + + def get_formset_kwargs(self) -> dict: + return super().get_form_kwargs() + + def form_valid(self, form) -> HttpResponse: + return self.formset_valid(form) + + def formset_valid(self, formset) -> HttpResponse: + return super().form_valid(formset) + + +@typing.runtime_checkable +class HasFormValid(Protocol): + def form_valid(self, form): + pass + + +class SaveValidFormMixin: + """ + Call `form.save()` if the submitted form is valid. + + Django's `ModelFormMixin` (which inherits from `SingleObjectMixin`) does the same, but cannot always be used, for + example if a formset for a collection of objects is submitted. + """ + + def form_valid(self: HasFormValid, form) -> HttpResponse: + form.save() + return super().form_valid(form) + + class AttachmentResponse(HttpResponse): """ Helper class that sets the correct Content-Disposition header for a given @@ -148,11 +211,11 @@ class AttachmentResponse(HttpResponse): _to the response instance_ as if it was a writable file. """ - def __init__(self, filename, content_type=None, **kwargs): + def __init__(self, filename: str, content_type=None, **kwargs) -> None: super().__init__(content_type=content_type, **kwargs) self.set_content_disposition(filename) - def set_content_disposition(self, filename): + def set_content_disposition(self, filename: str) -> None: try: filename.encode("ascii") self["Content-Disposition"] = f'attachment; filename="{filename}"' @@ -170,7 +233,7 @@ class HttpResponseNoContent(HttpResponse): status_code = 204 - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) del self["content-type"] @@ -199,7 +262,7 @@ class ExcelExporter(ABC): # have a sheet added at initialization. default_sheet_name: str | None = None - def __init__(self): + def __init__(self) -> None: self.workbook = xlwt.Workbook() self.cur_row = 0 self.cur_col = 0 @@ -208,7 +271,7 @@ def __init__(self): else: self.cur_sheet = None - def write_cell(self, label="", style="default"): + def write_cell(self, label: CellValue = "", style: str = "default") -> None: """Write a single cell and move to the next column.""" self.cur_sheet.write( self.cur_row, @@ -218,11 +281,11 @@ def write_cell(self, label="", style="default"): ) self.cur_col += 1 - def next_row(self): + def next_row(self) -> None: self.cur_col = 0 self.cur_row += 1 - def write_row(self, vals, style="default"): + def write_row(self, vals: Iterable[CV], style: str | typing.Callable[[CV], str] = "default") -> None: """ Write a cell for every value and go to the next row. Styling can be chosen @@ -233,16 +296,16 @@ def write_row(self, vals, style="default"): self.write_cell(val, style=style(val) if callable(style) else style) self.next_row() - def write_empty_row_with_styles(self, styles): + def write_empty_row_with_styles(self, styles: Iterable[str]) -> None: for style in styles: self.write_cell(None, style) self.next_row() @abstractmethod - def export_impl(self, *args, **kwargs): + def export_impl(self, *args, **kwargs) -> None: """Specify the logic to insert the data into the sheet here.""" - def export(self, response, *args, **kwargs): + def export(self, response, *args, **kwargs) -> None: """Convenience method to avoid some boilerplate.""" self.export_impl(*args, **kwargs) self.workbook.save(response) diff --git a/evap/evaluation/urls.py b/evap/evaluation/urls.py index 9694b0cbc..62c003210 100644 --- a/evap/evaluation/urls.py +++ b/evap/evaluation/urls.py @@ -12,5 +12,6 @@ path("contact", views.contact, name="contact"), path("key/", views.login_key_authentication, name="login_key_authentication"), path("profile", views.profile_edit, name="profile_edit"), + path("set_notes", views.set_notes, name="set_notes"), path("set_startpage", views.set_startpage, name="set_startpage"), ] diff --git a/evap/evaluation/views.py b/evap/evaluation/views.py index 1e38cf6c6..a21616c1c 100644 --- a/evap/evaluation/views.py +++ b/evap/evaluation/views.py @@ -15,8 +15,9 @@ from django.views.decorators.http import require_POST from django.views.i18n import set_language -from evap.evaluation.forms import LoginEmailForm, NewKeyForm, ProfileForm +from evap.evaluation.forms import LoginEmailForm, NewKeyForm, NotebookForm, ProfileForm from evap.evaluation.models import EmailTemplate, FaqSection, Semester, UserProfile +from evap.evaluation.tools import HttpResponseNoContent from evap.middleware import no_login_required logger = logging.getLogger(__name__) @@ -224,6 +225,14 @@ def profile_edit(request): @require_POST +def set_notes(request): + form = NotebookForm(request.POST, instance=request.user) + if form.is_valid(): + form.save() + return HttpResponseNoContent() + return HttpResponseBadRequest() + + def set_startpage(request): user = request.user startpage = request.POST.get("page") diff --git a/evap/grades/templates/grades_course_view.html b/evap/grades/templates/grades_course_view.html index 3145cd87f..1bf12162c 100644 --- a/evap/grades/templates/grades_course_view.html +++ b/evap/grades/templates/grades_course_view.html @@ -30,7 +30,7 @@

{{ course.name }} ({{ semester.name }})

{% if user.is_grade_publisher %} - {% endif %} @@ -45,8 +45,8 @@

{{ course.name }} ({{ semester.name }})

{% if user.is_grade_publisher %} - {% trans 'Upload new midterm grades' %} - {% trans 'Upload new final grades' %} + {% trans 'Upload new midterm grades' %} + {% trans 'Upload new final grades' %} {% endif %} {% endblock %} @@ -58,13 +58,14 @@

{{ course.name }} ({{ semester.name }})

{% include 'confirmation_modal.html' with modal_id='deleteGradedocumentModal' title=title question=question action_text=action_text btn_type='danger' %} {% endblock %} diff --git a/evap/grades/templates/grades_semester_view.html b/evap/grades/templates/grades_semester_view.html index 226472526..73ed72cd0 100644 --- a/evap/grades/templates/grades_semester_view.html +++ b/evap/grades/templates/grades_semester_view.html @@ -7,7 +7,7 @@ {{ block.super }} {% show_infotext "grades_pages" %} -
+

{{ semester.name }}

@@ -72,7 +72,7 @@

{% if not course.gets_no_grade_documents %} {% if num_final_grades == 0 %} -
@@ -85,7 +85,7 @@

{% else %} - + {% endif %} {% endif %} @@ -108,13 +108,18 @@

{% include 'confirmation_modal.html' with modal_id='confirmNouploadModal' title=title question=question action_text=action_text btn_type='primary' %} {% trans 'Will final grades be uploaded?' as title %} @@ -123,7 +128,7 @@

{% include 'confirmation_modal.html' with modal_id='confirmLateruploadModal' title=title question=question action_text=action_text btn_type='primary' %} {% endblock %} diff --git a/evap/grades/tests.py b/evap/grades/tests.py index c340009cf..1e02bfc27 100644 --- a/evap/grades/tests.py +++ b/evap/grades/tests.py @@ -135,7 +135,7 @@ def test_upload_final_grades(self): evaluation.save() self.helper_check_final_grade_upload(course, 0) - def test_toggle_no_grades(self): + def test_set_no_grades(self): evaluation = self.evaluation evaluation.manager_approve() evaluation.begin_evaluation() @@ -146,8 +146,8 @@ def test_toggle_no_grades(self): self.assertFalse(evaluation.course.gets_no_grade_documents) self.app.post( - "/grades/toggle_no_grades", - params={"course_id": evaluation.course.id}, + "/grades/set_no_grades", + params={"course_id": evaluation.course.id, "status": "1"}, user=self.grade_publisher, status=200, ) @@ -160,8 +160,17 @@ def test_toggle_no_grades(self): ) self.app.post( - "/grades/toggle_no_grades", - params={"course_id": evaluation.course.id}, + "/grades/set_no_grades", + params={"course_id": evaluation.course.id, "status": "0"}, + user=self.grade_publisher, + status=200, + ) + evaluation = Evaluation.objects.get(id=evaluation.id) + self.assertFalse(evaluation.course.gets_no_grade_documents) + + self.app.post( + "/grades/set_no_grades", + params={"course_id": evaluation.course.id, "status": "0"}, user=self.grade_publisher, status=200, ) diff --git a/evap/grades/urls.py b/evap/grades/urls.py index c932577a5..7fc0ad0bf 100644 --- a/evap/grades/urls.py +++ b/evap/grades/urls.py @@ -5,14 +5,13 @@ app_name = "grades" urlpatterns = [ - path("", views.index, name="index"), - + path("", views.IndexView.as_view(), name="index"), path("download/", views.download_grades, name="download_grades"), - path("semester/", views.semester_view, name="semester_view"), - path("course/", views.course_view, name="course_view"), + path("semester/", views.SemesterView.as_view(), name="semester_view"), + path("course/", views.CourseView.as_view(), name="course_view"), path("course//upload", views.upload_grades, name="upload_grades"), path("grade_document//edit", views.edit_grades, name="edit_grades"), path("delete_grades", views.delete_grades, name="delete_grades"), - path("toggle_no_grades", views.toggle_no_grades, name="toggle_no_grades"), + path("set_no_grades", views.set_no_grades, name="set_no_grades"), ] diff --git a/evap/grades/views.py b/evap/grades/views.py index 2e9556fe6..88d779df9 100644 --- a/evap/grades/views.py +++ b/evap/grades/views.py @@ -1,11 +1,14 @@ +from typing import Any + from django.conf import settings from django.contrib import messages -from django.core.exceptions import PermissionDenied +from django.core.exceptions import PermissionDenied, SuspiciousOperation from django.db.models.query import QuerySet from django.http import FileResponse, HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils.translation import gettext as _ from django.views.decorators.http import require_GET, require_POST +from django.views.generic import DetailView, TemplateView from evap.evaluation.auth import ( grade_downloader_required, @@ -19,12 +22,14 @@ @grade_publisher_required -def index(request): - template_data = { - "semesters": Semester.objects.filter(grade_documents_are_deleted=False), - "disable_breadcrumb_grades": True, - } - return render(request, "grades_index.html", template_data) +class IndexView(TemplateView): + template_name = "grades_index.html" + + def get_context_data(self, **kwargs) -> dict[str, Any]: + return super().get_context_data(**kwargs) | { + "semesters": Semester.objects.filter(grade_documents_are_deleted=False), + "disable_breadcrumb_grades": True, + } def course_grade_document_count_tuples(courses: QuerySet[Course]) -> list[tuple[Course, int, int]]: @@ -41,42 +46,51 @@ def course_grade_document_count_tuples(courses: QuerySet[Course]) -> list[tuple[ @grade_publisher_required -def semester_view(request, semester_id): - semester = get_object_or_404(Semester, id=semester_id) - if semester.grade_documents_are_deleted: - raise PermissionDenied - - courses = ( - semester.courses.filter(evaluations__wait_for_grade_upload_before_publishing=True) - .exclude(evaluations__state=Evaluation.State.NEW) - .distinct() - ) - courses = course_grade_document_count_tuples(courses) +class SemesterView(DetailView): + template_name = "grades_semester_view.html" + model = Semester + pk_url_kwarg = "semester_id" + + object: Semester + + def get_object(self, *args, **kwargs) -> Semester: + semester = super().get_object(*args, **kwargs) + if semester.grade_documents_are_deleted: + raise PermissionDenied + return semester + + def get_context_data(self, **kwargs) -> dict[str, Any]: + query = ( + self.object.courses.filter(evaluations__wait_for_grade_upload_before_publishing=True) + .exclude(evaluations__state=Evaluation.State.NEW) + .distinct() + ) + courses = course_grade_document_count_tuples(query) - template_data = { - "semester": semester, - "courses": courses, - "disable_if_archived": "disabled" if semester.grade_documents_are_deleted else "", - "disable_breadcrumb_semester": True, - } - return render(request, "grades_semester_view.html", template_data) + return super().get_context_data(**kwargs) | { + "courses": courses, + "disable_breadcrumb_semester": True, + } @grade_publisher_or_manager_required -def course_view(request, course_id): - course = get_object_or_404(Course, id=course_id) - semester = course.semester - if semester.grade_documents_are_deleted: - raise PermissionDenied +class CourseView(DetailView): + template_name = "grades_course_view.html" + model = Course + pk_url_kwarg = "course_id" - template_data = { - "semester": semester, - "course": course, - "grade_documents": course.grade_documents.all(), - "disable_if_archived": "disabled" if semester.grade_documents_are_deleted else "", - "disable_breadcrumb_course": True, - } - return render(request, "grades_course_view.html", template_data) + def get_object(self, *args, **kwargs) -> Course: + course = super().get_object(*args, **kwargs) + if course.semester.grade_documents_are_deleted: + raise PermissionDenied + return course + + def get_context_data(self, **kwargs) -> dict[str, Any]: + return super().get_context_data(**kwargs) | { + "semester": self.object.semester, + "grade_documents": self.object.grade_documents.all(), + "disable_breadcrumb_course": True, + } def on_grading_process_finished(course): @@ -134,12 +148,18 @@ def upload_grades(request, course_id): @require_POST @grade_publisher_required -def toggle_no_grades(request): +def set_no_grades(request): course = get_object_from_dict_pk_entry_or_logged_40x(Course, request.POST, "course_id") + + try: + status = bool(int(request.POST["status"])) + except (KeyError, TypeError, ValueError) as e: + raise SuspiciousOperation from e + if course.semester.grade_documents_are_deleted: raise PermissionDenied - course.gets_no_grade_documents = not course.gets_no_grade_documents + course.gets_no_grade_documents = status course.save() if course.gets_no_grade_documents: diff --git a/evap/locale/de/LC_MESSAGES/django.po b/evap/locale/de/LC_MESSAGES/django.po index fce24d05e..bf2497a7d 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: 2023-09-23 17:57+0200\n" -"PO-Revision-Date: 2023-09-23 17:58+0200\n" +"POT-Creation-Date: 2023-12-18 18:27+0100\n" +"PO-Revision-Date: 2023-12-18 18:43+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.3.2\n" +"X-Generator: Poedit 3.4.1\n" #: evap/contributor/forms.py:16 msgid "General questionnaires" @@ -22,15 +22,15 @@ msgstr "Allgemeine Fragebögen" #: evap/contributor/forms.py:19 #: evap/contributor/templates/contributor_evaluation_form.html:34 -#: evap/staff/templates/staff_course_type_index.html:25 -#: evap/staff/templates/staff_degree_index.html:21 +#: evap/staff/templates/staff_course_type_index.html:27 +#: evap/staff/templates/staff_degree_index.html:23 msgid "Name (German)" msgstr "Name (Deutsch)" #: evap/contributor/forms.py:20 #: evap/contributor/templates/contributor_evaluation_form.html:38 -#: evap/staff/templates/staff_course_type_index.html:26 -#: evap/staff/templates/staff_degree_index.html:22 +#: evap/staff/templates/staff_course_type_index.html:28 +#: evap/staff/templates/staff_degree_index.html:24 msgid "Name (English)" msgstr "Name (Englisch)" @@ -80,16 +80,16 @@ msgid "Responsibles" msgstr "Verantwortliche" #: evap/contributor/templates/contributor_evaluation_form.html:46 -#: evap/evaluation/templates/navbar.html:73 evap/results/exporters.py:185 -#: evap/results/templates/results_index.html:26 evap/staff/forms.py:1130 -#: evap/staff/templates/staff_degree_index.html:5 +#: evap/evaluation/templates/navbar.html:73 evap/results/exporters.py:189 +#: evap/results/templates/results_index.html:26 evap/staff/forms.py:1127 +#: evap/staff/templates/staff_degree_index.html:7 #: evap/staff/templates/staff_index.html:48 -#: evap/staff/templates/staff_semester_export.html:25 evap/staff/views.py:717 +#: evap/staff/templates/staff_semester_export.html:25 evap/staff/views.py:720 msgid "Degrees" msgstr "Studiengänge" #: evap/contributor/templates/contributor_evaluation_form.html:54 -#: evap/staff/views.py:815 +#: evap/staff/views.py:818 msgid "Course type" msgstr "Veranstaltungstyp" @@ -118,6 +118,7 @@ msgid "Preview" msgstr "Vorschau" #: evap/contributor/templates/contributor_evaluation_form.html:92 +#: evap/evaluation/templates/notebook.html:17 #: evap/evaluation/templates/profile.html:78 #: evap/staff/templates/staff_course_copyform.html:36 #: evap/staff/templates/staff_course_form.html:45 @@ -140,7 +141,7 @@ msgid "Cancel" msgstr "Abbrechen" #: evap/contributor/templates/contributor_evaluation_form.html:97 -#: evap/student/templates/student_vote.html:150 +#: evap/student/templates/student_vote.html:154 msgid "Back" msgstr "Zurück" @@ -164,12 +165,12 @@ msgstr "" "der Vorbereitung fortfahren, Sie können aber keine weiteren Änderungen mehr " "vornehmen." -#: evap/contributor/templates/contributor_evaluation_form.html:135 +#: evap/contributor/templates/contributor_evaluation_form.html:141 #, python-format msgid "Request account creation for %(evaluation_name)s" msgstr "Anlegen eines Accounts anfragen für %(evaluation_name)s" -#: evap/contributor/templates/contributor_evaluation_form.html:136 +#: evap/contributor/templates/contributor_evaluation_form.html:142 msgid "" "Please tell us which new account we should create. We need the name and " "email for all new accounts." @@ -177,12 +178,12 @@ msgstr "" "Bitte teilen Sie uns mit, welchen Account wir anlegen sollen. Wir benötigen " "einen Namen und eine E-Mail-Adresse für alle neuen Accounts." -#: evap/contributor/templates/contributor_evaluation_form.html:139 +#: evap/contributor/templates/contributor_evaluation_form.html:145 #, python-format msgid "Request evaluation changes for %(evaluation_name)s" msgstr "Änderung der Evaluierung anfragen für %(evaluation_name)s" -#: evap/contributor/templates/contributor_evaluation_form.html:140 +#: evap/contributor/templates/contributor_evaluation_form.html:146 msgid "Please tell us what changes to the evaluation we should make." msgstr "" "Bitte teilen Sie uns mit, welche Änderungen wir an der Evaluierung vornehmen " @@ -210,15 +211,15 @@ msgid "Hide" msgstr "Verbergen" #: evap/contributor/templates/contributor_index.html:49 -#: evap/evaluation/models.py:1955 +#: evap/evaluation/models.py:1959 #: evap/grades/templates/grades_semester_view.html:38 #: evap/staff/templates/staff_semester_preparation_reminder.html:40 #: evap/staff/templates/staff_semester_view.html:283 -#: evap/staff/templates/staff_user_list.html:44 evap/staff/views.py:716 +#: evap/staff/templates/staff_user_list.html:44 evap/staff/views.py:719 msgid "Name" msgstr "Name" -#: evap/contributor/templates/contributor_index.html:50 evap/staff/views.py:720 +#: evap/contributor/templates/contributor_index.html:50 evap/staff/views.py:723 msgid "State" msgstr "Zustand" @@ -261,7 +262,7 @@ msgid "Midterm evaluation" msgstr "Zwischenevaluierung" #: evap/contributor/templates/contributor_index.html:125 -#: evap/evaluation/templates/evaluation_badges.html:10 evap/staff/views.py:719 +#: evap/evaluation/templates/evaluation_badges.html:10 evap/staff/views.py:722 #: evap/student/templates/student_index_semester_evaluations_list.html:134 msgid "Single result" msgstr "Einzelergebnis" @@ -295,15 +296,15 @@ msgstr "" "Möchten Sie die Vorbereitung der Evaluierung wirklich delegieren?" -#: evap/contributor/views.py:202 evap/staff/views.py:1270 +#: evap/contributor/views.py:202 evap/staff/views.py:1282 msgid "Successfully updated and approved evaluation." msgstr "Evaluierung erfolgreich geändert und bestätigt." -#: evap/contributor/views.py:204 evap/staff/views.py:1272 +#: evap/contributor/views.py:204 evap/staff/views.py:1284 msgid "Successfully approved evaluation." msgstr "Evaluierung erfolgreich bestätigt." -#: evap/contributor/views.py:206 evap/staff/views.py:1274 +#: evap/contributor/views.py:206 evap/staff/views.py:1286 msgid "Successfully updated evaluation." msgstr "Evaluierung erfolgreich geändert." @@ -314,7 +315,7 @@ msgstr "" "Die Vorschau kann nicht angezeigt werden. Bitte beheben Sie die unten " "angezeigten Fehler." -#: evap/contributor/views.py:218 evap/staff/views.py:1292 +#: evap/contributor/views.py:218 evap/staff/views.py:1304 msgid "The form was not saved. Please resolve the errors shown below." msgstr "" "Das Formular wurde nicht gespeichert. Bitte beheben Sie die unten " @@ -334,8 +335,8 @@ msgid "Development" 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:764 -#: evap/staff/views.py:1485 +#: evap/staff/templates/staff_user_merge.html:56 evap/staff/views.py:767 +#: evap/staff/views.py:1497 msgid "Email" msgstr "E‑Mail" @@ -626,7 +627,7 @@ msgstr "Mitwirkende·r" msgid "Editor" msgstr "Bearbeiter·in" -#: evap/evaluation/models.py:1041 evap/evaluation/models.py:1704 +#: evap/evaluation/models.py:1041 evap/evaluation/models.py:1708 msgid "contributor" msgstr "Mitwirkende·r" @@ -1315,90 +1316,94 @@ msgstr "Sprache" msgid "Delegates" msgstr "Stellvertreter·innen" -#: evap/evaluation/models.py:1688 +#: evap/evaluation/models.py:1689 msgid "CC Users" msgstr "CC-Accounts" -#: evap/evaluation/models.py:1691 +#: evap/evaluation/models.py:1693 #: evap/staff/templates/staff_user_badges.html:17 msgid "Proxy user" msgstr "Proxy-Accounts" -#: evap/evaluation/models.py:1696 +#: evap/evaluation/models.py:1698 msgid "Login Key" msgstr "Anmeldeschlüssel" -#: evap/evaluation/models.py:1697 +#: evap/evaluation/models.py:1699 msgid "Login Key Validity" msgstr "Gültigkeit des Anmeldeschlüssels" -#: evap/evaluation/models.py:1699 +#: evap/evaluation/models.py:1701 msgid "active" msgstr "aktiv" -#: evap/evaluation/models.py:1702 +#: evap/evaluation/models.py:1703 +msgid "notes" +msgstr "Notizen" + +#: evap/evaluation/models.py:1706 msgid "default" msgstr "Standard" -#: evap/evaluation/models.py:1703 +#: evap/evaluation/models.py:1707 msgid "student" msgstr "Student·in" -#: evap/evaluation/models.py:1705 +#: evap/evaluation/models.py:1709 msgid "grades" msgstr "Noten" -#: evap/evaluation/models.py:1710 +#: evap/evaluation/models.py:1714 msgid "start page of the user" msgstr "Startseite des Accounts" -#: evap/evaluation/models.py:1722 +#: evap/evaluation/models.py:1726 msgid "user" msgstr "Account" -#: evap/evaluation/models.py:1723 +#: evap/evaluation/models.py:1727 msgid "users" msgstr "Accounts" -#: evap/evaluation/models.py:1957 +#: evap/evaluation/models.py:1961 #: evap/evaluation/templates/contact_modal.html:38 evap/staff/forms.py:661 #: evap/staff/forms.py:700 msgid "Subject" msgstr "Betreff" -#: evap/evaluation/models.py:1958 evap/staff/forms.py:662 +#: evap/evaluation/models.py:1962 evap/staff/forms.py:662 #: evap/staff/forms.py:701 #: evap/staff/templates/staff_email_preview_form.html:18 msgid "Plain Text" msgstr "Plain Text" -#: evap/evaluation/models.py:1959 evap/staff/forms.py:663 +#: evap/evaluation/models.py:1963 evap/staff/forms.py:663 #: evap/staff/forms.py:702 #: evap/staff/templates/staff_email_preview_form.html:21 msgid "HTML" msgstr "HTML" -#: evap/evaluation/models.py:1972 +#: evap/evaluation/models.py:1976 msgid "all participants" msgstr "Alle Teilnehmenden" -#: evap/evaluation/models.py:1973 +#: evap/evaluation/models.py:1977 msgid "due participants" msgstr "Ausstehende Teilnehmende" -#: evap/evaluation/models.py:1974 +#: evap/evaluation/models.py:1978 msgid "responsible person" msgstr "verantwortliche Person" -#: evap/evaluation/models.py:1975 +#: evap/evaluation/models.py:1979 msgid "all editors" msgstr "alle Bearbeiter·innen" -#: evap/evaluation/models.py:1976 +#: evap/evaluation/models.py:1980 msgid "all contributors" msgstr "alle Mitwirkenden" -#: evap/evaluation/models.py:2195 +#: evap/evaluation/models.py:2199 msgid "vote timestamp" msgstr "Zeitstempel der Abstimmung" @@ -1469,23 +1474,23 @@ msgstr "" msgid "Evaluation Platform" msgstr "Evaluierungsplattform" -#: evap/evaluation/templates/base.html:133 +#: evap/evaluation/templates/base.html:154 msgid "Please select..." msgstr "Bitte auswählen..." -#: evap/evaluation/templates/base.html:140 +#: evap/evaluation/templates/base.html:161 msgid "Please enter ${ minimumInputLength } characters or more..." msgstr "Bitte mindestens ${ minimumInputLength } Zeichen eingeben..." -#: evap/evaluation/templates/base.html:142 +#: evap/evaluation/templates/base.html:163 msgid "No results found" msgstr "Keine Ergebnisse gefunden" -#: evap/evaluation/templates/base.html:155 +#: evap/evaluation/templates/base.html:176 msgid "Remove all items" msgstr "Alle Einträge entfernen" -#: evap/evaluation/templates/base.html:156 +#: evap/evaluation/templates/base.html:177 msgid "Remove this item" msgstr "Diesen Eintrag entfernen" @@ -1510,7 +1515,7 @@ msgid "Send Message" msgstr "Nachricht senden" #: evap/evaluation/templates/contribution_formset.html:5 -#: evap/results/templates/results_evaluation_detail.html:137 +#: evap/results/templates/results_evaluation_detail.html:167 msgid "Contributors" msgstr "Mitwirkende" @@ -1623,8 +1628,8 @@ msgid "HPI login" msgstr "HPI-Login" #: evap/evaluation/templates/index.html:17 -msgid "Log in using HPI OpenID Connect." -msgstr "Anmeldung mit HPI OpenID Connect." +msgid "Log in using Keycloak." +msgstr "Anmeldung mit Keycloak." #: evap/evaluation/templates/index.html:20 #: evap/evaluation/templates/index.html:34 @@ -1717,7 +1722,7 @@ msgid "Rewards" msgstr "Belohnungen" #: evap/evaluation/templates/navbar.html:31 -#: evap/results/templates/results_evaluation_detail.html:75 +#: evap/results/templates/results_evaluation_detail.html:105 #: evap/staff/templates/staff_semester_view.html:65 msgid "Overview" msgstr "Übersicht" @@ -1762,8 +1767,8 @@ msgid "More" msgstr "Weitere" #: evap/evaluation/templates/navbar.html:72 -#: evap/results/templates/results_index.html:39 evap/staff/forms.py:1135 -#: evap/staff/templates/staff_course_type_index.html:5 +#: evap/results/templates/results_index.html:39 evap/staff/forms.py:1132 +#: evap/staff/templates/staff_course_type_index.html:7 #: evap/staff/templates/staff_course_type_merge.html:7 #: evap/staff/templates/staff_course_type_merge_selection.html:5 #: evap/staff/templates/staff_index.html:56 @@ -1809,6 +1814,28 @@ msgstr "Profil" msgid "Logout" msgstr "Abmelden" +#: evap/evaluation/templates/notebook.html:6 +msgid "Notebook" +msgstr "Notizbuch" + +#: evap/evaluation/templates/notebook.html:18 +msgid "Sending..." +msgstr "Senden..." + +#: evap/evaluation/templates/notebook.html:19 +msgid "Saved successfully" +msgstr "Erfolgreich gespeichert" + +#: evap/evaluation/templates/notebook.html:22 +msgid "" +"Here you can store private notes that you want to keep ready for future " +"evaluations. The notes will be stored in plain text in your account on the " +"EvaP server, but will not be shown to anyone but you." +msgstr "" +"Hier kannst du private Notizen anlegen, die du für spätere Evaluierungen " +"bereithalten möchtest. Die Notizen werden im Klartext in deinem Konto auf " +"dem EvaP-Server gespeichert, werden aber nur dir angezeigt." + #: evap/evaluation/templates/profile.html:15 msgid "Personal information" msgstr "Persönliche Daten" @@ -1872,22 +1899,6 @@ msgstr "Kann die Evaluierung zur Vorbereitung bearbeiten." msgid "Default value. No special rights." 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:157 -#: evap/staff/templates/staff_evaluation_textanswers_section.html:52 -#: evap/staff/templates/staff_questionnaire_index_list.html:55 -#: evap/staff/templates/staff_semester_view.html:28 -#: evap/staff/templates/staff_semester_view.html:423 -#: evap/staff/templates/staff_semester_view_evaluation.html:211 -msgid "Delete" -msgstr "Löschen" - -#: evap/evaluation/templates/sortable_form_js.html:21 -msgid "add another" -msgstr "Weitere·n hinzufügen" - #: evap/evaluation/templates/startpage_button.html:2 msgid "This is your startpage" msgstr "Das ist die aktuelle Startseite" @@ -2026,18 +2037,18 @@ msgstr "Von Bearbeiter·in bestätigt, Bestätigung durch Manager·in ausstehend msgid "Approved by manager" msgstr "Von Manager·in bestätigt" -#: evap/evaluation/views.py:76 +#: evap/evaluation/views.py:77 msgid "" "We sent you an email with a one-time login URL. Please check your inbox." msgstr "" "Wir haben Ihnen einen einmalig gültigen Anmeldelink zugesendet. Bitte " "überprüfen Sie Ihren Posteingang." -#: evap/evaluation/views.py:118 +#: evap/evaluation/views.py:119 msgid "Inactive users are not allowed to login." msgstr "Inaktive Accounts können sich nicht anmelden." -#: evap/evaluation/views.py:126 +#: evap/evaluation/views.py:127 msgid "" "Another user is currently logged in. Please logout first and then use the " "login URL again." @@ -2045,12 +2056,12 @@ msgstr "" "Ein anderer Account ist bereits angemeldet. Bitte melden Sie sich zuerst ab " "und klicken Sie dann erneut auf den Anmeldelink." -#: evap/evaluation/views.py:138 +#: evap/evaluation/views.py:139 #, python-format msgid "Logged in as %s." msgstr "Angemeldet als %s." -#: evap/evaluation/views.py:146 +#: evap/evaluation/views.py:147 msgid "" "The login URL is not valid anymore. We sent you a new one to your email " "address." @@ -2058,11 +2069,11 @@ msgstr "" "Der Anmeldelink ist nicht mehr gültig. Wir haben Ihnen einen neuen Link an " "Ihre E-Mail-Adresse gesendet." -#: evap/evaluation/views.py:148 +#: evap/evaluation/views.py:149 msgid "Invalid login URL. Please request a new one below." msgstr "Ungültiger Anmeldelink. Bitte fordern Sie unten einen neuen an." -#: evap/evaluation/views.py:212 +#: evap/evaluation/views.py:213 msgid "Successfully updated your profile." msgstr "Profil erfolgreich aktualisiert." @@ -2123,7 +2134,7 @@ msgstr "Hochgeladene Noten-Dokumente" msgid "Description" msgstr "Beschreibung" -#: evap/grades/templates/grades_course_view.html:18 evap/staff/views.py:718 +#: evap/grades/templates/grades_course_view.html:18 evap/staff/views.py:721 msgid "Type" msgstr "Typ" @@ -2134,10 +2145,10 @@ msgstr "Zuletzt verändert" #: evap/grades/templates/grades_course_view.html:20 #: evap/grades/templates/grades_semester_view.html:43 #: evap/rewards/templates/rewards_reward_point_redemption_event_list.html:13 -#: evap/staff/templates/staff_course_type_index.html:28 -#: evap/staff/templates/staff_degree_index.html:24 -#: evap/staff/templates/staff_faq_index.html:23 -#: evap/staff/templates/staff_faq_section.html:24 +#: evap/staff/templates/staff_course_type_index.html:30 +#: evap/staff/templates/staff_degree_index.html:26 +#: evap/staff/templates/staff_faq_index.html:25 +#: evap/staff/templates/staff_faq_section.html:26 #: evap/staff/templates/staff_questionnaire_index_list.html:21 #: evap/staff/templates/staff_text_answer_warnings.html:34 msgid "Actions" @@ -2159,6 +2170,17 @@ msgstr "Herunterladen" msgid "Edit" msgstr "Bearbeiten" +#: 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:157 +#: evap/staff/templates/staff_evaluation_textanswers_section.html:49 +#: evap/staff/templates/staff_questionnaire_index_list.html:55 +#: evap/staff/templates/staff_semester_view.html:28 +#: evap/staff/templates/staff_semester_view.html:424 +#: evap/staff/templates/staff_semester_view_evaluation.html:211 +msgid "Delete" +msgstr "Löschen" + #: evap/grades/templates/grades_course_view.html:43 msgid "No grade documents have been uploaded yet" msgstr "Es wurden noch keine Noten-Dokumente hochgeladen" @@ -2184,21 +2206,22 @@ msgstr "" "Soll das Noten-Dokument wirklich gelöscht " "werden?" -#: evap/grades/templates/grades_course_view.html:66 -#: evap/rewards/templates/rewards_reward_point_redemption_events.html:43 -#: evap/staff/templates/staff_questionnaire_index.html:61 -#: evap/staff/templates/staff_questionnaire_index.html:89 -#: evap/staff/templates/staff_questionnaire_index.html:104 -#: evap/staff/templates/staff_semester_preparation_reminder.html:92 -#: evap/staff/templates/staff_semester_view.html:460 -#: evap/staff/templates/staff_semester_view.html:478 -#: evap/staff/templates/staff_semester_view.html:493 -#: evap/staff/templates/staff_semester_view.html:508 -#: evap/staff/templates/staff_semester_view.html:523 -#: evap/staff/templates/staff_semester_view.html:538 -#: evap/staff/templates/staff_semester_view.html:553 -#: evap/staff/templates/staff_user_form.html:123 -#: evap/staff/templates/staff_user_form.html:139 +#: evap/grades/templates/grades_course_view.html:68 +#: evap/grades/templates/grades_semester_view.html:122 +#: evap/rewards/templates/rewards_reward_point_redemption_events.html:46 +#: evap/staff/templates/staff_questionnaire_index.html:63 +#: evap/staff/templates/staff_questionnaire_index.html:95 +#: evap/staff/templates/staff_questionnaire_index.html:110 +#: evap/staff/templates/staff_semester_preparation_reminder.html:91 +#: evap/staff/templates/staff_semester_view.html:464 +#: evap/staff/templates/staff_semester_view.html:484 +#: evap/staff/templates/staff_semester_view.html:500 +#: evap/staff/templates/staff_semester_view.html:516 +#: evap/staff/templates/staff_semester_view.html:532 +#: evap/staff/templates/staff_semester_view.html:548 +#: evap/staff/templates/staff_semester_view.html:564 +#: evap/staff/templates/staff_user_form.html:135 +#: evap/staff/templates/staff_user_form.html:152 msgid "The server is not responding." msgstr "Der Server reagiert nicht." @@ -2211,14 +2234,14 @@ msgstr "Es gibt noch keine Semester." #: evap/results/templates/results_index.html:23 #: evap/staff/templates/staff_questionnaire_index.html:26 #: evap/staff/templates/staff_semester_view.html:178 -#: evap/staff/templates/staff_semester_view.html:360 +#: evap/staff/templates/staff_semester_view.html:361 #: evap/staff/templates/staff_user_list.html:29 msgid "Search..." msgstr "Suchen..." #: evap/grades/templates/grades_semester_view.html:22 #: evap/staff/templates/staff_semester_view.html:179 -#: evap/staff/templates/staff_semester_view.html:361 +#: evap/staff/templates/staff_semester_view.html:362 #: evap/staff/templates/staff_user_list.html:31 msgid "Clear search filter" msgstr "Suchfilter entfernen" @@ -2229,10 +2252,10 @@ msgid "Courses" msgstr "Veranstaltungen" #: evap/grades/templates/grades_semester_view.html:39 -#: evap/results/templates/results_evaluation_detail.html:99 +#: evap/results/templates/results_evaluation_detail.html:129 #: evap/results/templates/results_index.html:79 #: evap/results/templates/results_index.html:98 -#: evap/staff/templates/staff_semester_view.html:378 +#: evap/staff/templates/staff_semester_view.html:379 #: evap/staff/templates/staff_user_badges.html:14 msgid "Responsible" msgstr "Verantwortliche·r" @@ -2293,16 +2316,16 @@ msgstr "" "label=\"\"> eingereicht wurden, aber nicht hochgeladen werden." #: evap/grades/templates/grades_semester_view.html:107 -#: evap/grades/templates/grades_semester_view.html:122 +#: evap/grades/templates/grades_semester_view.html:127 #: evap/staff/templates/staff_evaluation_operation.html:60 msgid "Confirm" msgstr "Bestätigen" -#: evap/grades/templates/grades_semester_view.html:120 +#: evap/grades/templates/grades_semester_view.html:125 msgid "Will final grades be uploaded?" msgstr "Werden Endnoten hochgeladen?" -#: evap/grades/templates/grades_semester_view.html:121 +#: evap/grades/templates/grades_semester_view.html:126 msgid "" "Please confirm that a grade document for the course will be uploaded later on." @@ -2347,57 +2370,52 @@ msgstr "Zwischennoten für %(course)s (%(semester)s) hochladen" msgid "Upload grades" msgstr "Noten hochladen" -#: evap/grades/views.py:122 +#: evap/grades/views.py:136 msgid "Successfully uploaded grades." msgstr "Noten erfolgreich hochgeladen." -#: evap/grades/views.py:177 +#: evap/grades/views.py:197 msgid "Successfully updated grades." msgstr "Noten erfolgreich aktualisiert." -#: evap/results/exporters.py:173 -msgid "" -"{}\n" -"\n" -"{}\n" -"\n" -"{}" -msgstr "" -"{}\n" -"\n" -"{}\n" -"\n" -"{}" +#: evap/results/exporters.py:165 +#: evap/results/templates/results_evaluation_detail.html:127 +#: evap/results/templates/results_index.html:77 +#: evap/results/templates/results_index.html:96 evap/staff/forms.py:125 +#: evap/student/templates/student_index.html:6 +#: evap/student/templates/student_vote.html:7 +msgid "Evaluation" +msgstr "Evaluierung" -#: evap/results/exporters.py:190 +#: evap/results/exporters.py:194 msgid "Course Type" msgstr "Veranstaltungstyp" -#: evap/results/exporters.py:201 +#: evap/results/exporters.py:205 msgid "Overall Average Grade" msgstr "Gesamtdurchschnittsnote" -#: evap/results/exporters.py:205 +#: evap/results/exporters.py:209 msgid "Total voters/Total participants" msgstr "Anzahl Abstimmende/Anzahl Teilnehmende" -#: evap/results/exporters.py:209 +#: evap/results/exporters.py:213 msgid "Evaluation rate" msgstr "Teilnahmequote" -#: evap/results/exporters.py:225 +#: evap/results/exporters.py:229 msgid "Evaluation weight" msgstr "Gewichtung der Evaluierung" -#: evap/results/exporters.py:229 +#: evap/results/exporters.py:233 msgid "Course Grade" msgstr "Veranstaltungsnote" -#: evap/results/exporters.py:340 +#: evap/results/exporters.py:350 msgid "Text Answers" msgstr "Textantworten" -#: evap/results/exporters.py:360 +#: evap/results/exporters.py:370 msgid "Export for {}" msgstr "Export für {}" @@ -2432,12 +2450,12 @@ msgstr "" "Diese Evaluierung ist privat. Nur Mitwirkende und Teilnehmende können die " "Ergebnisse sehen." -#: evap/results/templates/results_evaluation_detail.html:33 -#: evap/staff/templates/staff_evaluation_textanswers.html:16 +#: evap/results/templates/results_evaluation_detail.html:36 +#: evap/staff/templates/staff_evaluation_textanswers.html:19 msgid "View" msgstr "Ansicht" -#: evap/results/templates/results_evaluation_detail.html:37 +#: evap/results/templates/results_evaluation_detail.html:45 msgid "" "Shows filtered view meant for personal export. Other contributors' results " "and private answers are hidden." @@ -2445,21 +2463,21 @@ msgstr "" "Zeigt gefilterte Ansicht für persönlichen Export. Ergebnisse anderer " "Mitwirkender und private Antworten sind ausgeblendet." -#: evap/results/templates/results_evaluation_detail.html:38 +#: evap/results/templates/results_evaluation_detail.html:47 msgctxt "view mode" msgid "Export" msgstr "Export" -#: evap/results/templates/results_evaluation_detail.html:42 +#: evap/results/templates/results_evaluation_detail.html:57 msgid "Shows all results available for you." msgstr "Zeigt alle für Sie verfügbaren Ergebnisse an." -#: evap/results/templates/results_evaluation_detail.html:43 -#: evap/staff/templates/staff_evaluation_textanswers.html:22 +#: evap/results/templates/results_evaluation_detail.html:59 +#: evap/staff/templates/staff_evaluation_textanswers.html:33 msgid "Full" msgstr "Alle" -#: evap/results/templates/results_evaluation_detail.html:47 +#: evap/results/templates/results_evaluation_detail.html:68 msgid "" "The results of this evaluation have not been published because it didn't get " "enough votes." @@ -2467,69 +2485,61 @@ msgstr "" "Die Ergebnisse dieser Evaluierung wurden nicht veröffentlicht, weil nicht " "genügend Teilnehmende abgestimmt haben." -#: evap/results/templates/results_evaluation_detail.html:49 -#: evap/results/templates/results_evaluation_detail.html:59 +#: evap/results/templates/results_evaluation_detail.html:71 +#: evap/results/templates/results_evaluation_detail.html:87 msgid "Participant" msgstr "Teilnehmende" -#: evap/results/templates/results_evaluation_detail.html:51 -#: evap/results/templates/results_evaluation_detail.html:62 +#: evap/results/templates/results_evaluation_detail.html:73 +#: evap/results/templates/results_evaluation_detail.html:91 msgid "Public" msgstr "Öffentlich" -#: evap/results/templates/results_evaluation_detail.html:58 +#: evap/results/templates/results_evaluation_detail.html:85 msgid "Shows results available for the participants." msgstr "Zeigt Ergebnisse an, die für die Teilnehmenden sichtbar sind." -#: evap/results/templates/results_evaluation_detail.html:61 +#: evap/results/templates/results_evaluation_detail.html:89 msgid "Shows results available for everyone logged in." msgstr "Zeigt Ergebnisse an, die für alle angemeldeten Accounts sichtbar sind." -#: evap/results/templates/results_evaluation_detail.html:78 +#: evap/results/templates/results_evaluation_detail.html:108 msgid "Export text answers" msgstr "Textantworten exportieren" -#: evap/results/templates/results_evaluation_detail.html:85 +#: evap/results/templates/results_evaluation_detail.html:115 msgid "Grades" 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:125 -#: evap/student/templates/student_index.html:6 -#: evap/student/templates/student_vote.html:7 -msgid "Evaluation" -msgstr "Evaluierung" - -#: evap/results/templates/results_evaluation_detail.html:98 +#: evap/results/templates/results_evaluation_detail.html:128 #: evap/results/templates/results_index.html:52 #: evap/results/templates/results_index.html:78 #: evap/results/templates/results_index.html:97 msgid "Semester" msgstr "Semester" -#: evap/results/templates/results_evaluation_detail.html:100 +#: evap/results/templates/results_evaluation_detail.html:130 #: evap/results/templates/results_index.html:80 msgid "Voters" msgstr "Abstimmende" -#: evap/results/templates/results_evaluation_detail.html:101 +#: evap/results/templates/results_evaluation_detail.html:131 #: evap/results/templates/results_index.html:81 #: evap/results/templates/results_index.html:99 msgid "Distribution and average grade" msgstr "Verteilung und Durchschnittsnote" -#: evap/results/templates/results_evaluation_detail.html:124 -#: evap/results/templates/results_evaluation_detail.html:190 +#: evap/results/templates/results_evaluation_detail.html:154 +#: evap/results/templates/results_evaluation_detail.html:220 #: evap/staff/importers/base.py:29 msgid "General" msgstr "Allgemein" -#: evap/results/templates/results_evaluation_detail.html:156 +#: evap/results/templates/results_evaluation_detail.html:186 msgid "There are no results for this person." msgstr "Für diese Person gibt es keine Ergebnisse." -#: evap/results/templates/results_evaluation_detail.html:172 +#: evap/results/templates/results_evaluation_detail.html:202 msgid "Other contributors" msgstr "Andere Mitwirkende" @@ -2604,12 +2614,12 @@ msgid "Redemptions" msgstr "Einlösungen" #: evap/rewards/exporters.py:12 evap/staff/templates/staff_user_merge.html:50 -#: evap/staff/views.py:1485 +#: evap/staff/views.py:1497 msgid "Last name" msgstr "Nachname" #: evap/rewards/exporters.py:13 evap/staff/templates/staff_user_merge.html:38 -#: evap/staff/views.py:1485 +#: evap/staff/views.py:1497 msgid "First name" msgstr "Vorname" @@ -2813,23 +2823,23 @@ msgid "" "well." msgstr "Wir freuen uns auch auf dein Feedback in den anderen Evaluierungen." -#: evap/rewards/views.py:43 +#: evap/rewards/views.py:47 msgid "You successfully redeemed your points." msgstr "Punkte erfolgreich eingelöst." -#: evap/rewards/views.py:47 +#: evap/rewards/views.py:56 msgid "You cannot redeem 0 points." msgstr "Du kannst nicht 0 Punkte einlösen." -#: evap/rewards/views.py:49 +#: evap/rewards/views.py:58 msgid "You don't have enough reward points." msgstr "Du hast nicht genügend Belohnungspunkte." -#: evap/rewards/views.py:51 +#: evap/rewards/views.py:60 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 +#: evap/rewards/views.py:64 msgid "" "It appears that your browser sent multiple redemption requests. You can see " "all successful redemptions below." @@ -2837,19 +2847,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:75 +#: evap/rewards/views.py:84 msgid "Reward for" msgstr "Belohnung für" -#: evap/rewards/views.py:113 +#: evap/rewards/views.py:121 msgid "Successfully created event." msgstr "Veranstaltung erfolgreich erstellt." -#: evap/rewards/views.py:127 +#: evap/rewards/views.py:130 msgid "Successfully updated event." msgstr "Veranstaltung erfolgreich geändert." -#: evap/rewards/views.py:148 +#: evap/rewards/views.py:150 msgid "RewardPoints" msgstr "Belohnungspunkte" @@ -2896,8 +2906,8 @@ msgstr "Beginn der Evaluierungen" msgid "Last day of evaluations" msgstr "Letzter Tag der Evaluierungen" -#: evap/staff/forms.py:360 evap/student/templates/student_vote.html:78 -#: evap/student/templates/student_vote.html:121 +#: evap/staff/forms.py:360 evap/student/templates/student_vote.html:82 +#: evap/student/templates/student_vote.html:125 msgid "General questions" msgstr "Allgemeine Fragen" @@ -3003,14 +3013,14 @@ msgstr "Inaktiv" msgid "Evaluations participating in (active semester)" msgstr "Teilnahme an (aktuelles Semester)" -#: evap/staff/forms.py:985 +#: evap/staff/forms.py:986 #, 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:1002 +#: evap/staff/forms.py:1004 #, python-format msgid "A user with the email '%s' already exists" msgstr "Ein Account mit der E-Mail-Adresse '%s' existiert bereits" @@ -3129,7 +3139,7 @@ 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:283 #, python-brace-format msgid "" "{location}: No course type is associated with the import name " @@ -3138,7 +3148,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:293 #, python-brace-format msgid "" "{location}: \"is_graded\" is {is_graded}, but must be {is_graded_yes} or " @@ -3147,7 +3157,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:305 #, python-brace-format msgid "" "{location}: No degree is associated with the import name \"{degree}\". " @@ -3156,30 +3166,30 @@ msgstr "" "{location}: Kein Studiengang ist mit dem Import-Namen \"{degree}\" " "verknüpft. Bitte manuell anlegen." -#: evap/staff/importers/enrollment.py:333 +#: evap/staff/importers/enrollment.py:340 msgid "the course type does not match" msgstr "der Veranstaltungstyp stimmt nicht überein" -#: evap/staff/importers/enrollment.py:337 +#: evap/staff/importers/enrollment.py:344 msgid "the responsibles of the course do not match" msgstr "die Verantwortlichen der Veranstaltung stimmen nicht überein" -#: evap/staff/importers/enrollment.py:341 +#: evap/staff/importers/enrollment.py:348 msgid "the existing course does not have exactly one evaluation" msgstr "die bestehende Veranstaltung hat nicht genau eine Evaluierung" -#: evap/staff/importers/enrollment.py:347 +#: evap/staff/importers/enrollment.py:354 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:350 +#: evap/staff/importers/enrollment.py:357 msgid "the evaluation of the existing course is a single result" msgstr "die Evaluierung der bestehenden Veranstaltung ist ein Einzelergebnis" -#: evap/staff/importers/enrollment.py:355 +#: evap/staff/importers/enrollment.py:362 msgid "" "the import would add participants to the existing evaluation but the " "evaluation is already running" @@ -3187,7 +3197,7 @@ msgstr "" "der Import würde Teilnehmende zu der existierenden Evaluierung hinzufügen, " "diese läuft aber bereits" -#: evap/staff/importers/enrollment.py:440 +#: evap/staff/importers/enrollment.py:447 #, python-brace-format msgid "" "Course \"{course_name}\" already exists. The course will not be created, " @@ -3199,7 +3209,7 @@ msgstr "" "der bestehenden Veranstaltung importiert und alle zusätzlichen Studiengänge " "werden hinzugefügt." -#: evap/staff/importers/enrollment.py:451 +#: evap/staff/importers/enrollment.py:458 #, python-brace-format msgid "" "{location}: Course {course_name} already exists in this semester, but the " @@ -3209,7 +3219,7 @@ msgstr "" "bereits, aber die Veranstaltungen können aus diesen Gründen nicht " "zusammengeführt werden:{reasons}" -#: evap/staff/importers/enrollment.py:463 +#: evap/staff/importers/enrollment.py:470 #, python-brace-format msgid "" "{location}: Course \"{course_name}\" (DE) already exists in this semester " @@ -3218,7 +3228,7 @@ msgstr "" "{location}: Veranstaltung \"{course_name}\" (DE) existiert bereits in diesem " "Semester mit anderem englischen Namen." -#: evap/staff/importers/enrollment.py:474 +#: evap/staff/importers/enrollment.py:481 #, python-brace-format msgid "" "{location}: Course \"{course_name}\" (EN) already exists in this semester " @@ -3227,7 +3237,7 @@ msgstr "" "{location}: Veranstaltung \"{course_name}\" (EN) existiert bereits in diesem " "Semester mit anderem deutschen Namen." -#: evap/staff/importers/enrollment.py:485 +#: evap/staff/importers/enrollment.py:492 #, python-brace-format msgid "" "{location}: The German name for course \"{course_name}\" is already used for " @@ -3236,7 +3246,7 @@ msgstr "" "{location}: Der deutsche Name der Veranstaltung \"{course_name}\" existiert " "bereits für eine andere Veranstaltung in der Importdatei." -#: evap/staff/importers/enrollment.py:523 +#: evap/staff/importers/enrollment.py:530 #, python-brace-format msgid "" "{location}: The course names \"{name1}\" and \"{name2}\" have a low edit " @@ -3245,7 +3255,7 @@ msgstr "" "{location}: Die Veranstaltungsnamen \"{name1}\" und \"{name2}\" haben eine " "geringe Editierdistanz." -#: evap/staff/importers/enrollment.py:570 +#: evap/staff/importers/enrollment.py:577 #, python-brace-format msgid "" "Course {course_name}: 1 participant from the import file already " @@ -3260,7 +3270,7 @@ msgstr[1] "" "Veranstaltung {course_name}: {participant_count} Teilnehmende aus der Import-" "Datei sind bereits für die Evaluierung eingetragen." -#: evap/staff/importers/enrollment.py:602 +#: evap/staff/importers/enrollment.py:609 #, python-brace-format msgid "" "{location}: The data of course \"{name}\" differs from its data in the " @@ -3269,7 +3279,7 @@ msgstr "" "{location}: Die Daten der Veranstaltung \"{name}\" unterscheiden sich in den " "Spalten ({columns}) von den Daten in einer vorherigen Zeile." -#: evap/staff/importers/enrollment.py:638 +#: evap/staff/importers/enrollment.py:645 #, python-brace-format msgid "" "{location}: The degree of user \"{email}\" differs from their degree in a " @@ -3278,36 +3288,36 @@ msgstr "" "{location}: Der Studiengang für den Account \"{email}\" unterscheidet sich " "vom Studiengang in einer vorherigen Zeile." -#: evap/staff/importers/enrollment.py:664 +#: evap/staff/importers/enrollment.py:671 msgid "Warning: User {} has {} enrollments, which is a lot." msgstr "Warnung: Account {} hat ungewöhnlich viele Belegungen ({})." -#: evap/staff/importers/enrollment.py:737 evap/staff/importers/user.py:347 +#: evap/staff/importers/enrollment.py:744 evap/staff/importers/user.py:347 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:738 +#: evap/staff/importers/enrollment.py:745 #, python-brace-format msgid "The import run will create {evaluation_string} and {user_string}" msgstr "Der Import wird {evaluation_string} und {user_string} erstellen" -#: evap/staff/importers/enrollment.py:740 -#: evap/staff/importers/enrollment.py:756 +#: evap/staff/importers/enrollment.py:747 +#: evap/staff/importers/enrollment.py:763 #, python-brace-format msgid "1 course/evaluation" msgid_plural "{count} courses/evaluations" msgstr[0] "1 Veranstaltung/Evaluierung" msgstr[1] "{count} Veranstaltungen/Evaluierungen" -#: evap/staff/importers/enrollment.py:742 +#: evap/staff/importers/enrollment.py:749 #, python-brace-format msgid "1 user" msgid_plural "{count} users" msgstr[0] "1 Account" msgstr[1] "{count} Accounts" -#: evap/staff/importers/enrollment.py:754 +#: evap/staff/importers/enrollment.py:761 #, python-brace-format msgid "" "Successfully created {evaluation_string}, {participant_string} and " @@ -3316,14 +3326,14 @@ msgstr "" "Erfolgreich {evaluation_string}, {participant_string} und " "{contributor_string} erstellt" -#: evap/staff/importers/enrollment.py:758 +#: evap/staff/importers/enrollment.py:765 #, python-brace-format msgid "1 participant" msgid_plural "{count} participants" msgstr[0] "1 Teilnehmende·r" msgstr[1] "{count} Teilnehmende" -#: evap/staff/importers/enrollment.py:761 +#: evap/staff/importers/enrollment.py:768 #, python-brace-format msgid "1 contributor" msgid_plural "{count} contributors" @@ -3504,7 +3514,7 @@ msgid "There are no evaluations for this course." msgstr "Es gibt keine Evaluierungen für diese Veranstaltung." #: evap/staff/templates/staff_course_form.html:9 -#: evap/staff/templates/staff_semester_view.html:354 +#: evap/staff/templates/staff_semester_view.html:355 msgid "Create course" msgstr "Veranstaltung erstellen" @@ -3521,7 +3531,7 @@ msgstr "Speichern und Evaluierung erstellen" msgid "Save and create single result" msgstr "Speichern und Einzelergebnis erstellen" -#: evap/staff/templates/staff_course_type_index.html:12 +#: evap/staff/templates/staff_course_type_index.html:14 #: evap/staff/templates/staff_course_type_merge.html:8 #: evap/staff/templates/staff_course_type_merge.html:13 #: evap/staff/templates/staff_course_type_merge.html:35 @@ -3531,12 +3541,12 @@ msgstr "Speichern und Einzelergebnis erstellen" msgid "Merge course types" msgstr "Veranstaltungstypen zusammenführen" -#: evap/staff/templates/staff_course_type_index.html:27 -#: evap/staff/templates/staff_degree_index.html:23 +#: evap/staff/templates/staff_course_type_index.html:29 +#: evap/staff/templates/staff_degree_index.html:25 msgid "Import names" msgstr "Import-Namen" -#: evap/staff/templates/staff_course_type_index.html:54 +#: evap/staff/templates/staff_course_type_index.html:56 msgid "" "This course type cannot be deleted because it is used for at least one " "course." @@ -3544,7 +3554,7 @@ msgstr "" "Dieser Veranstaltungstyp kann nicht gelöscht werden, weil er für mindestens " "eine Veranstaltung verwendet wird." -#: evap/staff/templates/staff_course_type_index.html:67 +#: evap/staff/templates/staff_course_type_index.html:69 msgid "Save course types" msgstr "Veranstaltungstypen speichern" @@ -3572,14 +3582,14 @@ msgstr "Wähle die Veranstaltungstypen aus, die zusammengeführt werden sollen." msgid "Show merge info" msgstr "Zeige Zusammenführung" -#: evap/staff/templates/staff_degree_index.html:50 +#: evap/staff/templates/staff_degree_index.html:52 msgid "" "This degree cannot be deleted because it is used for at least one course." msgstr "" "Dieser Studiengang kann nicht gelöscht werden, weil er für mindestens eine " "Veranstaltung verwendet wird." -#: evap/staff/templates/staff_degree_index.html:63 +#: evap/staff/templates/staff_degree_index.html:65 msgid "Save degrees" msgstr "Studiengänge speichern" @@ -3605,7 +3615,7 @@ msgstr "Zeige Empfänger·innen" #: evap/staff/templates/staff_evaluation_email.html:47 #: evap/staff/templates/staff_semester_send_reminder.html:28 #: evap/staff/templates/staff_semester_view_evaluation.html:193 -#: evap/staff/templates/staff_user_form.html:130 +#: evap/staff/templates/staff_user_form.html:141 msgid "Send email" msgstr "E‑Mail senden" @@ -3734,17 +3744,17 @@ msgstr "Zuvor hochgeladene Datei importieren" #: evap/staff/templates/staff_evaluation_person_management.html:32 #: evap/staff/templates/staff_evaluation_person_management.html:51 -#: evap/staff/templates/staff_evaluation_person_management.html:147 -#: evap/staff/templates/staff_evaluation_person_management.html:149 -#: evap/staff/templates/staff_evaluation_person_management.html:175 -#: evap/staff/templates/staff_evaluation_person_management.html:177 +#: evap/staff/templates/staff_evaluation_person_management.html:150 +#: evap/staff/templates/staff_evaluation_person_management.html:152 +#: evap/staff/templates/staff_evaluation_person_management.html:184 +#: evap/staff/templates/staff_evaluation_person_management.html:186 msgid "Replace participants" msgstr "Teilnehmende ersetzen" #: evap/staff/templates/staff_evaluation_person_management.html:44 #: evap/staff/templates/staff_evaluation_person_management.html:50 -#: evap/staff/templates/staff_evaluation_person_management.html:161 -#: evap/staff/templates/staff_evaluation_person_management.html:163 +#: evap/staff/templates/staff_evaluation_person_management.html:167 +#: evap/staff/templates/staff_evaluation_person_management.html:169 msgid "Copy participants" msgstr "Teilnehmende kopieren" @@ -3758,8 +3768,8 @@ msgid "Copy participants from another evaluation." msgstr "Teilnehmende aus anderer Evaluierung kopieren." #: evap/staff/templates/staff_evaluation_person_management.html:64 -#: evap/staff/templates/staff_evaluation_person_management.html:189 -#: evap/staff/templates/staff_evaluation_person_management.html:191 +#: evap/staff/templates/staff_evaluation_person_management.html:201 +#: evap/staff/templates/staff_evaluation_person_management.html:203 msgid "Import contributors" msgstr "Mitwirkende importieren" @@ -3769,17 +3779,17 @@ msgstr "Excel-Datei mit Mitwirkenden hochladen" #: evap/staff/templates/staff_evaluation_person_management.html:81 #: evap/staff/templates/staff_evaluation_person_management.html:100 -#: evap/staff/templates/staff_evaluation_person_management.html:203 -#: evap/staff/templates/staff_evaluation_person_management.html:205 -#: evap/staff/templates/staff_evaluation_person_management.html:231 -#: evap/staff/templates/staff_evaluation_person_management.html:233 +#: evap/staff/templates/staff_evaluation_person_management.html:218 +#: evap/staff/templates/staff_evaluation_person_management.html:220 +#: evap/staff/templates/staff_evaluation_person_management.html:252 +#: evap/staff/templates/staff_evaluation_person_management.html:254 msgid "Replace contributors" msgstr "Mitwirkende ersetzen" #: evap/staff/templates/staff_evaluation_person_management.html:93 #: evap/staff/templates/staff_evaluation_person_management.html:99 -#: evap/staff/templates/staff_evaluation_person_management.html:217 -#: evap/staff/templates/staff_evaluation_person_management.html:219 +#: evap/staff/templates/staff_evaluation_person_management.html:235 +#: evap/staff/templates/staff_evaluation_person_management.html:237 msgid "Copy contributors" msgstr "Mitwirkende kopieren" @@ -3812,7 +3822,7 @@ msgstr "Diese Evaluierung hat keine externen Teilnehmenden." msgid "Do you really want to import the Excel file with participant data?" msgstr "Möchtest du die Excel-Datei mit Teilnehmenden wirklich importieren?" -#: evap/staff/templates/staff_evaluation_person_management.html:148 +#: evap/staff/templates/staff_evaluation_person_management.html:151 msgid "" "Do you really want to delete all existing participants and replace them with " "participant data from the Excel file?" @@ -3820,11 +3830,11 @@ msgstr "" "Möchtest du wirklich alle bisherigen Teilnehmenden löschen und sie durch die " "Teilnehmenden aus der Exceldatei ersetzen?" -#: evap/staff/templates/staff_evaluation_person_management.html:162 +#: evap/staff/templates/staff_evaluation_person_management.html:168 msgid "Do you really want to copy the participants?" msgstr "Möchtest du die Teilnehmenden wirklich kopieren?" -#: evap/staff/templates/staff_evaluation_person_management.html:176 +#: evap/staff/templates/staff_evaluation_person_management.html:185 msgid "" "Do you really want to delete all existing participants and copy the " "participants into the evaluation?" @@ -3832,11 +3842,11 @@ msgstr "" "Möchtest du wirklich alle bisherigen Teilnehmenden löschen und die " "Teilnehmenden in die Evaluierung kopieren?" -#: evap/staff/templates/staff_evaluation_person_management.html:190 +#: evap/staff/templates/staff_evaluation_person_management.html:202 msgid "Do you really want to import the Excel file with contributor data?" msgstr "Möchtest du die Excel-Datei mit Mitwirkenden wirklich importieren?" -#: evap/staff/templates/staff_evaluation_person_management.html:204 +#: evap/staff/templates/staff_evaluation_person_management.html:219 msgid "" "Do you really want to delete all existing contributors and replace them with " "contributor data from the Excel file?" @@ -3844,11 +3854,11 @@ msgstr "" "Möchtest du wirklich alle bisherigen Mitwirkenden löschen und sie durch die " "Mitwirkenden aus der Exceldatei ersetzen?" -#: evap/staff/templates/staff_evaluation_person_management.html:218 +#: evap/staff/templates/staff_evaluation_person_management.html:236 msgid "Do you really want to copy the contributors?" msgstr "Möchtest du die Mitwirkenden wirklich kopieren?" -#: evap/staff/templates/staff_evaluation_person_management.html:232 +#: evap/staff/templates/staff_evaluation_person_management.html:253 msgid "" "Do you really want to delete all existing contributors and copy the " "contributors into the evaluation?" @@ -3863,7 +3873,7 @@ msgid "Text answers" msgstr "Textantworten" #: evap/staff/templates/staff_evaluation_textanswer_edit.html:11 -#: evap/staff/templates/staff_evaluation_textanswers_section.html:14 +#: evap/staff/templates/staff_evaluation_textanswers_section.html:13 msgid "Text answer" msgstr "Textantwort" @@ -3871,28 +3881,28 @@ msgstr "Textantwort" msgid "Save text answer" msgstr "Textantwort speichern" -#: evap/staff/templates/staff_evaluation_textanswers.html:19 +#: evap/staff/templates/staff_evaluation_textanswers.html:26 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.html:40 +#: evap/staff/templates/staff_evaluation_textanswers_section.html:46 msgid "Undecided" msgstr "Offen" -#: evap/staff/templates/staff_evaluation_textanswers.html:28 +#: evap/staff/templates/staff_evaluation_textanswers.html:47 msgid "Flagged" msgstr "Markiert" -#: evap/staff/templates/staff_evaluation_textanswers.html:35 +#: evap/staff/templates/staff_evaluation_textanswers.html:55 msgid "Still in evaluation" msgstr "Noch in Evaluierung" -#: evap/staff/templates/staff_evaluation_textanswers.html:37 +#: evap/staff/templates/staff_evaluation_textanswers.html:57 msgid "Evaluation finished" msgstr "Evaluierung beendet" -#: evap/staff/templates/staff_evaluation_textanswers.html:42 +#: evap/staff/templates/staff_evaluation_textanswers.html:62 msgid "" "If you select \"delete\" for a text answer on this page, the respective " "answer will be deleted irrevocably when publishing the evaluation's results. " @@ -4021,8 +4031,8 @@ msgid "Unreview" msgstr "Entscheidung zurücknehmen" #: evap/staff/templates/staff_evaluation_textanswers_quick.html:150 -#: evap/staff/templates/staff_evaluation_textanswers_section.html:43 -#: evap/staff/templates/staff_semester_view.html:338 +#: evap/staff/templates/staff_evaluation_textanswers_section.html:40 +#: evap/staff/templates/staff_semester_view.html:339 msgid "Publish" msgstr "Veröffentlichen" @@ -4033,44 +4043,44 @@ msgstr "" "markiert werden." #: evap/staff/templates/staff_evaluation_textanswers_quick.html:154 -#: evap/staff/templates/staff_evaluation_textanswers_section.html:46 +#: evap/staff/templates/staff_evaluation_textanswers_section.html:43 msgid "Private" msgstr "Privat" -#: evap/staff/templates/staff_evaluation_textanswers_section.html:16 +#: evap/staff/templates/staff_evaluation_textanswers_section.html:15 msgid "Decision" msgstr "Entscheidung" -#: evap/staff/templates/staff_evaluation_textanswers_section.html:17 +#: evap/staff/templates/staff_evaluation_textanswers_section.html:16 msgid "Flag" msgstr "Markierung" -#: evap/staff/templates/staff_faq_index.html:5 -#: evap/staff/templates/staff_faq_section.html:5 +#: evap/staff/templates/staff_faq_index.html:7 +#: evap/staff/templates/staff_faq_section.html:7 msgid "FAQ Sections" msgstr "FAQ-Abschnitte" -#: evap/staff/templates/staff_faq_index.html:21 +#: evap/staff/templates/staff_faq_index.html:23 msgid "Section title (German)" msgstr "Abschnittstitel (Deutsch)" -#: evap/staff/templates/staff_faq_index.html:22 +#: evap/staff/templates/staff_faq_index.html:24 msgid "Section title (English)" msgstr "Abschnittstitel (Englisch)" -#: evap/staff/templates/staff_faq_index.html:53 +#: evap/staff/templates/staff_faq_index.html:55 msgid "Save FAQ sections" msgstr "FAQ-Abschnitte speichern" -#: evap/staff/templates/staff_faq_section.html:22 +#: evap/staff/templates/staff_faq_section.html:24 msgid "Question/Answer (German)" msgstr "Frage/Antwort (Deutsch)" -#: evap/staff/templates/staff_faq_section.html:23 +#: evap/staff/templates/staff_faq_section.html:25 msgid "Question/Answer (English)" msgstr "Fragetext/Antwort (Englisch)" -#: evap/staff/templates/staff_faq_section.html:53 +#: evap/staff/templates/staff_faq_section.html:55 msgid "Save FAQ section" msgstr "FAQ-Abschnitt speichern" @@ -4163,32 +4173,32 @@ msgstr "Titel/Inhalt (Englisch)" msgid "Save infotexts" msgstr "Infotexte speichern" -#: evap/staff/templates/staff_questionnaire_form.html:7 +#: evap/staff/templates/staff_questionnaire_form.html:9 msgid "Some fields are disabled as this questionnaire is already in use." msgstr "" "Manche Felder sind deaktiviert, weil der Fragebogen bereits verwendet wird." -#: evap/staff/templates/staff_questionnaire_form.html:15 +#: evap/staff/templates/staff_questionnaire_form.html:17 msgid "General Options" msgstr "Allgemeine Optionen" -#: evap/staff/templates/staff_questionnaire_form.html:24 +#: evap/staff/templates/staff_questionnaire_form.html:26 msgid "Questions" msgstr "Fragen" -#: evap/staff/templates/staff_questionnaire_form.html:31 +#: evap/staff/templates/staff_questionnaire_form.html:33 msgid "Question text (German)" msgstr "Fragetext (Deutsch)" -#: evap/staff/templates/staff_questionnaire_form.html:32 +#: evap/staff/templates/staff_questionnaire_form.html:34 msgid "Question text (English)" msgstr "Fragetext (Englisch)" -#: evap/staff/templates/staff_questionnaire_form.html:33 +#: evap/staff/templates/staff_questionnaire_form.html:35 msgid "Question type" msgstr "Fragetyp" -#: evap/staff/templates/staff_questionnaire_form.html:73 +#: evap/staff/templates/staff_questionnaire_form.html:75 msgid "Save questionnaire" msgstr "Fragebogen speichern" @@ -4422,7 +4432,7 @@ msgid "This is the active semester" msgstr "Dies ist das aktive Semester" #: evap/staff/templates/staff_semester_view.html:26 -#: evap/staff/templates/staff_semester_view.html:448 +#: evap/staff/templates/staff_semester_view.html:449 msgid "Make active" msgstr "Als aktiv markieren" @@ -4432,8 +4442,8 @@ msgstr "Archivierung" #: evap/staff/templates/staff_semester_view.html:40 #: evap/staff/templates/staff_semester_view.html:46 -#: evap/staff/templates/staff_semester_view.html:482 -#: evap/staff/templates/staff_semester_view.html:484 +#: evap/staff/templates/staff_semester_view.html:487 +#: evap/staff/templates/staff_semester_view.html:489 msgid "Archive participations" msgstr "Teilnahmen archivieren" @@ -4446,8 +4456,8 @@ msgid "The participations in this semester can not be archived." msgstr "Die Teilnahmen in diesem Semester können nicht archiviert werden." #: evap/staff/templates/staff_semester_view.html:49 -#: evap/staff/templates/staff_semester_view.html:497 -#: evap/staff/templates/staff_semester_view.html:499 +#: evap/staff/templates/staff_semester_view.html:503 +#: evap/staff/templates/staff_semester_view.html:505 msgid "Delete grade documents" msgstr "Noten-Dokumente löschen" @@ -4456,8 +4466,8 @@ msgid "Grade documents have been deleted" msgstr "Noten-Dokumente wurden gelöscht" #: evap/staff/templates/staff_semester_view.html:54 -#: evap/staff/templates/staff_semester_view.html:512 -#: evap/staff/templates/staff_semester_view.html:514 +#: evap/staff/templates/staff_semester_view.html:519 +#: evap/staff/templates/staff_semester_view.html:521 msgid "Archive results" msgstr "Ergebnisse archivieren" @@ -4562,95 +4572,95 @@ msgstr "Es existieren keine Evaluierungen in diesem Semester." msgid "Select all" msgstr "Alle auswählen" -#: evap/staff/templates/staff_semester_view.html:312 +#: evap/staff/templates/staff_semester_view.html:313 msgid "Select none" msgstr "Nichts auswählen" -#: evap/staff/templates/staff_semester_view.html:317 +#: evap/staff/templates/staff_semester_view.html:318 msgid "Evaluations in preparation or approved by editors" msgstr "Evaluierungen in Vorbereitung oder von Bearbeiter·innen bestätigt" -#: evap/staff/templates/staff_semester_view.html:320 +#: evap/staff/templates/staff_semester_view.html:321 msgid "Ask for editor review" msgstr "Für Bestätigung durch Bearbeiter·innen freigeben" -#: evap/staff/templates/staff_semester_view.html:323 +#: evap/staff/templates/staff_semester_view.html:324 msgid "" "Evaluations awaiting editor review, approved by editor or approved by manager" msgstr "" "Bestätigung von Bearbeiter·innen ausstehend oder von Bearbeiter·innen oder " "Manager·innen bestätigt" -#: evap/staff/templates/staff_semester_view.html:327 +#: evap/staff/templates/staff_semester_view.html:328 msgid "Revert to preparation" msgstr "Auf \"in Vorbereitung\" zurücksetzen" -#: evap/staff/templates/staff_semester_view.html:330 +#: evap/staff/templates/staff_semester_view.html:331 msgid "Evaluations waiting for evaluation period to start" msgstr "Evaluierungen, die auf Beginn des Evaluierungszeitraumes warten" -#: evap/staff/templates/staff_semester_view.html:332 +#: evap/staff/templates/staff_semester_view.html:333 msgid "Start evaluation" msgstr "Evaluierung beginnen" -#: evap/staff/templates/staff_semester_view.html:335 +#: evap/staff/templates/staff_semester_view.html:336 msgid "Unpublished evaluations" msgstr "Nicht veröffentlichte Evaluierungen" -#: evap/staff/templates/staff_semester_view.html:341 +#: evap/staff/templates/staff_semester_view.html:342 msgid "Published evaluations" msgstr "Veröffentlichte Evaluierungen" -#: evap/staff/templates/staff_semester_view.html:343 +#: evap/staff/templates/staff_semester_view.html:344 msgid "Unpublish" msgstr "Zurückziehen" -#: evap/staff/templates/staff_semester_view.html:377 +#: evap/staff/templates/staff_semester_view.html:378 msgid "Course" msgstr "Veranstaltung" -#: evap/staff/templates/staff_semester_view.html:379 +#: evap/staff/templates/staff_semester_view.html:380 msgid "#Evaluations" msgstr "#Evaluierungen" -#: evap/staff/templates/staff_semester_view.html:399 +#: evap/staff/templates/staff_semester_view.html:400 msgid "No evaluations" msgstr "Keine Evaluierungen" -#: evap/staff/templates/staff_semester_view.html:408 +#: evap/staff/templates/staff_semester_view.html:409 msgid "Create evaluation for this course" msgstr "Evaluierung für diese Veranstaltung erstellen" -#: evap/staff/templates/staff_semester_view.html:413 +#: evap/staff/templates/staff_semester_view.html:414 msgid "Create single result for this course" msgstr "Einzelergebnis für diese Veranstaltung erstellen" -#: evap/staff/templates/staff_semester_view.html:418 +#: evap/staff/templates/staff_semester_view.html:419 msgid "Copy course" msgstr "Veranstaltung kopieren" -#: evap/staff/templates/staff_semester_view.html:435 +#: evap/staff/templates/staff_semester_view.html:436 msgid "There are no courses in this semester." msgstr "Es existieren keine Veranstaltungen in diesem Semester." -#: evap/staff/templates/staff_semester_view.html:446 +#: evap/staff/templates/staff_semester_view.html:447 msgid "Make this the active semester" msgstr "Dieses Semester als aktiv markieren" -#: evap/staff/templates/staff_semester_view.html:447 +#: evap/staff/templates/staff_semester_view.html:448 msgid "Do you want to make this the active semester?" msgstr "Möchtest du dieses Semester als aktiv markieren?" -#: evap/staff/templates/staff_semester_view.html:454 +#: evap/staff/templates/staff_semester_view.html:455 msgid "Activating..." msgstr "Aktiviere..." -#: evap/staff/templates/staff_semester_view.html:464 -#: evap/staff/templates/staff_semester_view.html:466 +#: evap/staff/templates/staff_semester_view.html:467 +#: evap/staff/templates/staff_semester_view.html:469 msgid "Delete semester" msgstr "Semester löschen" -#: evap/staff/templates/staff_semester_view.html:465 +#: evap/staff/templates/staff_semester_view.html:468 msgid "" "Do you really want to delete the semester ? " "All courses and evaluations will be deleted as well as all results. If you " @@ -4660,11 +4670,11 @@ msgstr "" "Alle Veranstaltungen und Evaluierungen werden gelöscht, ebenso alle " "Ergebnisse. Wenn du sicher bist, gib den Namen des Semesters unten ein." -#: evap/staff/templates/staff_semester_view.html:472 +#: evap/staff/templates/staff_semester_view.html:475 msgid "Deleting..." msgstr "Löschen..." -#: evap/staff/templates/staff_semester_view.html:483 +#: evap/staff/templates/staff_semester_view.html:488 msgid "" "Do you really want to archive all participations in the semester ? Further changes to the evaluations won't be " @@ -4674,7 +4684,7 @@ msgstr "" "strong> archivieren? Änderungen an den Evaluierungen sind dann nicht mehr " "möglich und die Aktion kann nicht rückgängig gemacht werden." -#: evap/staff/templates/staff_semester_view.html:498 +#: evap/staff/templates/staff_semester_view.html:504 msgid "" "Do you really want to delete the grade documents in the semester ? This will delete all existing grade documents for " @@ -4686,7 +4696,7 @@ msgstr "" "die Veranstaltungen dieses Semesters löschen und das Hochladen neuer Noten-" "Dokumente deaktivieren. Diese Aktion kann nicht rückgängig gemacht werden." -#: evap/staff/templates/staff_semester_view.html:513 +#: evap/staff/templates/staff_semester_view.html:520 msgid "" "Do you really want to archive the results in the semester ? This will make the results of all evaluations " @@ -4698,12 +4708,12 @@ msgstr "" "für deren Mitwirkende und für Manager verfügbar. Diese Aktion kann nicht " "rückgängig gemacht werden." -#: evap/staff/templates/staff_semester_view.html:527 -#: evap/staff/templates/staff_semester_view.html:529 +#: evap/staff/templates/staff_semester_view.html:535 +#: evap/staff/templates/staff_semester_view.html:537 msgid "Delete evaluation" msgstr "Evaluierung löschen" -#: evap/staff/templates/staff_semester_view.html:528 +#: evap/staff/templates/staff_semester_view.html:536 msgid "" "Do you really want to delete the evaluation ?" @@ -4711,24 +4721,24 @@ msgstr "" "Soll die Evaluierung wirklich gelöscht " "werden?" -#: evap/staff/templates/staff_semester_view.html:542 -#: evap/staff/templates/staff_semester_view.html:544 +#: evap/staff/templates/staff_semester_view.html:551 +#: evap/staff/templates/staff_semester_view.html:553 msgid "Delete course" msgstr "Veranstaltung löschen" -#: evap/staff/templates/staff_semester_view.html:543 +#: evap/staff/templates/staff_semester_view.html:552 msgid "" "Do you really want to delete the course ?" msgstr "" "Soll die Veranstaltung wirklich gelöscht " "werden?" -#: evap/staff/templates/staff_semester_view.html:557 -#: evap/staff/templates/staff_semester_view.html:559 +#: evap/staff/templates/staff_semester_view.html:567 +#: evap/staff/templates/staff_semester_view.html:569 msgid "Activate reward points" msgstr "Belohnungspunkte aktivieren" -#: evap/staff/templates/staff_semester_view.html:558 +#: evap/staff/templates/staff_semester_view.html:568 msgid "" "Do you want to activate the reward points for the semester ? The activation will allow participants to receive " @@ -4916,34 +4926,50 @@ msgstr "Account bearbeiten" msgid "Resend evaluation started email" msgstr "E-Mail über den Evaluierungsbeginn erneut senden" -#: evap/staff/templates/staff_user_form.html:50 +#: evap/staff/templates/staff_user_form.html:27 +msgid "This user currently has no due evaluations." +msgstr "Für diesen Account stehen keine offenen Evaluierungen aus." + +#: evap/staff/templates/staff_user_form.html:38 +msgid "" +"A user with this email address already exists. You probably want to merge " +"the users." +msgstr "" +"Es gibt bereits einen Account mit dieser E-Mail-Adresse. Wahrscheinlich " +"sollten diese zusammengeführt werden." + +#: evap/staff/templates/staff_user_form.html:40 +msgid "Merge both users" +msgstr "Accounts zusammenführen" + +#: evap/staff/templates/staff_user_form.html:60 msgid "Represented Users" msgstr "Stellvertreter·in für" -#: evap/staff/templates/staff_user_form.html:56 +#: evap/staff/templates/staff_user_form.html:66 msgid "CC-User for" msgstr "CC-Account für" -#: evap/staff/templates/staff_user_form.html:64 +#: evap/staff/templates/staff_user_form.html:74 msgid "Export evaluation results" msgstr "Evaluierungs-Ergebnisse exportieren" -#: evap/staff/templates/staff_user_form.html:66 +#: evap/staff/templates/staff_user_form.html:76 msgid "Export all results" msgstr "Alle Ergebnisse exportieren" -#: evap/staff/templates/staff_user_form.html:94 +#: evap/staff/templates/staff_user_form.html:104 msgid "Save user" msgstr "Account speichern" -#: evap/staff/templates/staff_user_form.html:97 -#: evap/staff/templates/staff_user_form.html:102 +#: evap/staff/templates/staff_user_form.html:107 #: evap/staff/templates/staff_user_form.html:112 -#: evap/staff/templates/staff_user_form.html:114 +#: evap/staff/templates/staff_user_form.html:122 +#: evap/staff/templates/staff_user_form.html:124 msgid "Delete user" msgstr "Account löschen" -#: evap/staff/templates/staff_user_form.html:100 +#: evap/staff/templates/staff_user_form.html:110 msgid "" "This user contributes to an evaluation, participates in an evaluation whose " "participations haven't been archived yet or has special rights and as such " @@ -4953,7 +4979,7 @@ msgstr "" "teil, deren Teilnahmen noch nicht archiviert wurden oder hat besondere " "Berechtigungen und kann deswegen nicht gelöscht werden." -#: evap/staff/templates/staff_user_form.html:113 +#: evap/staff/templates/staff_user_form.html:123 msgid "" "Do you really want to delete the user ?
This person will also be removed from every other user having this person " @@ -4963,11 +4989,11 @@ msgstr "" "Der Account wird damit auch bei allen anderen Accounts als Stellvertreter·in " "und CC-Account entfernt." -#: evap/staff/templates/staff_user_form.html:128 +#: evap/staff/templates/staff_user_form.html:139 msgid "Send notification email" msgstr "E‑Mail-Benachrichtigung senden" -#: evap/staff/templates/staff_user_form.html:129 +#: evap/staff/templates/staff_user_form.html:140 msgid "" "The email will notify the user about all their due evaluations. Do you want " "to send the email now?" @@ -5115,32 +5141,32 @@ msgstr "In diesem Testlauf wurden keine Daten geändert." msgid "Users have been successfully updated." msgstr "Accounts wurden erfolgreich aktualisiert." -#: evap/staff/tools.py:360 +#: evap/staff/tools.py:361 msgid "{} will be removed from the delegates of {}." msgstr "{} wird aus der Liste der Stellvertreter·innen von {} entfernt." -#: evap/staff/tools.py:365 +#: evap/staff/tools.py:366 msgid "Removed {} from the delegates of {}." msgstr "{} aus der Liste der Stellvertreter·innen von {} entfernt." -#: evap/staff/tools.py:370 +#: evap/staff/tools.py:371 msgid "{} will be removed from the CC users of {}." msgstr "{} wird aus der Liste der CC-Accounts von {} entfernt." -#: evap/staff/tools.py:374 +#: evap/staff/tools.py:375 msgid "Removed {} from the CC users of {}." msgstr "{} aus der Liste der CC-Accounts von {} entfernt." -#: evap/staff/tools.py:382 +#: evap/staff/tools.py:383 msgid "edit user" msgstr "Account bearbeiten" -#: evap/staff/views.py:272 +#: evap/staff/views.py:275 msgid "Do you want to revert the following evaluations to preparation?" msgstr "" "Möchtest du die folgenden Evaluierungen auf \"in Vorbereitung\" zurücksetzen?" -#: evap/staff/views.py:281 evap/staff/views.py:317 +#: evap/staff/views.py:284 evap/staff/views.py:320 msgid "" "{} evaluation can not be reverted, because it already started. It was " "removed from the selection." @@ -5154,29 +5180,29 @@ msgstr[1] "" "{} Evaluierungen können nicht zurückgesetzt werden, weil sie bereits " "begonnen haben. Sie wurden aus der Auswahl entfernt." -#: evap/staff/views.py:299 +#: evap/staff/views.py:302 msgid "Successfully reverted {} evaluation to in preparation." msgid_plural "Successfully reverted {} evaluations to in preparation." msgstr[0] "Erfolgreich {} Evaluierung auf \"in Vorbereitung\" zurückgesetzt." msgstr[1] "Erfolgreich {} Evaluierungen auf \"in Vorbereitung\" zurückgesetzt." -#: evap/staff/views.py:308 +#: evap/staff/views.py:311 msgid "Do you want to send the following evaluations to editor review?" msgstr "" "Möchtest du die folgenden Evaluierungen für die Bestätigung durch " "Bearbeiter·innen freigeben?" -#: evap/staff/views.py:335 +#: evap/staff/views.py:338 msgid "Successfully enabled {} evaluation for editor review." msgid_plural "Successfully enabled {} evaluations for editor review." msgstr[0] "Erfolgreich {} Evaluierung für Bearbeiter·innen freigegeben." msgstr[1] "Erfolgreich {} Evaluierungen für Bearbeiter·innen freigegeben." -#: evap/staff/views.py:364 +#: evap/staff/views.py:367 msgid "Do you want to immediately start the following evaluations?" msgstr "Möchtest du die folgenden Evaluierungen sofort beginnen?" -#: evap/staff/views.py:373 +#: evap/staff/views.py:376 msgid "" "{} evaluation can not be started, because it was not approved, was already " "evaluated or its evaluation end date lies in the past. It was removed from " @@ -5194,18 +5220,18 @@ msgstr[1] "" "wurden, bereits evaluiert wurden oder die Enddaten der Evaluierungszeiträume " "in der Vergangenheit liegen. Sie wurden aus der Auswahl entfernt." -#: evap/staff/views.py:392 +#: evap/staff/views.py:395 msgid "Successfully started {} evaluation." msgid_plural "Successfully started {} evaluations." msgstr[0] "{} Evaluierung erfolgreich gestartet." msgstr[1] "{} Evaluierungen erfolgreich gestartet." -#: evap/staff/views.py:402 +#: evap/staff/views.py:405 msgid "Do you want to unpublish the following evaluations?" msgstr "" "Möchtest du die Veröffentlichung der folgenden Evaluierungen zurückziehen?" -#: evap/staff/views.py:411 +#: evap/staff/views.py:414 msgid "" "{} evaluation can not be unpublished, because it's results have not been " "published. It was removed from the selection." @@ -5220,17 +5246,17 @@ msgstr[1] "" "weil sie noch nicht veröffentlicht wurden. Sie wurden aus der Auswahl " "entfernt." -#: evap/staff/views.py:429 +#: evap/staff/views.py:432 msgid "Successfully unpublished {} evaluation." msgid_plural "Successfully unpublished {} evaluations." msgstr[0] "Veröffentlichung von {} Evaluierung erfolgreich zurückgezogen." msgstr[1] "Veröffentlichung von {} Evaluierungen erfolgreich zurückgezogen." -#: evap/staff/views.py:437 +#: evap/staff/views.py:440 msgid "Do you want to publish the following evaluations?" msgstr "Möchtest du die folgenden Evaluierungen veröffentlichen?" -#: evap/staff/views.py:446 +#: evap/staff/views.py:449 msgid "" "{} evaluation can not be published, because it's not finished or not all of " "its text answers have been reviewed. It was removed from the selection." @@ -5247,117 +5273,117 @@ msgstr[1] "" "abgeschlossen sind oder nicht alle Textantworten überprüft wurden. Sie " "wurden aus der Auswahl entfernt." -#: evap/staff/views.py:463 +#: evap/staff/views.py:466 msgid "Successfully published {} evaluation." msgid_plural "Successfully published {} evaluations." msgstr[0] "{} Evaluierung erfolgreich veröffentlicht." msgstr[1] "{} Evaluierungen erfolgreich veröffentlicht." -#: evap/staff/views.py:544 +#: evap/staff/views.py:547 msgid "Please select at least one evaluation." msgstr "Bitte wähle mindestens eine Evaluierung aus." -#: evap/staff/views.py:579 +#: evap/staff/views.py:581 msgid "Successfully created semester." msgstr "Semester erfolgreich erstellt." -#: evap/staff/views.py:605 +#: evap/staff/views.py:594 msgid "Successfully updated semester." msgstr "Semester erfolgreich geändert." -#: evap/staff/views.py:721 +#: evap/staff/views.py:724 msgid "#Voters" msgstr "#Abstimmende" -#: evap/staff/views.py:722 +#: evap/staff/views.py:725 msgid "#Participants" msgstr "#Teilnehmende" -#: evap/staff/views.py:723 +#: evap/staff/views.py:726 msgid "#Text answers" msgstr "#Textantworten" -#: evap/staff/views.py:724 +#: evap/staff/views.py:727 msgid "Average grade" msgstr "Durchschnittsnote" -#: evap/staff/views.py:765 +#: evap/staff/views.py:768 msgid "Can use reward points" msgstr "Kann Belohnungspunkte nutzen" -#: evap/staff/views.py:766 +#: evap/staff/views.py:769 msgid "#Required evaluations voted for" msgstr "#bewertete benötigte Evaluierungen" -#: evap/staff/views.py:767 +#: evap/staff/views.py:770 msgid "#Required evaluations" msgstr "#benötigte Evaluierungen" -#: evap/staff/views.py:768 +#: evap/staff/views.py:771 msgid "#Optional evaluations voted for" msgstr "#bewertete optionale Evaluierungen" -#: evap/staff/views.py:769 +#: evap/staff/views.py:772 msgid "#Optional evaluations" msgstr "#optionale Evaluierungen" -#: evap/staff/views.py:770 +#: evap/staff/views.py:773 msgid "Earned reward points" msgstr "Belohnungspunkte erhalten" -#: evap/staff/views.py:814 +#: evap/staff/views.py:817 msgid "Evaluation id" msgstr "Evaluierungs-ID" -#: evap/staff/views.py:816 +#: evap/staff/views.py:819 msgid "Course degrees" msgstr "Studiengänge der Veranstaltung" -#: evap/staff/views.py:817 +#: evap/staff/views.py:820 msgid "Vote end date" msgstr "Enddatum der Evaluierung" -#: evap/staff/views.py:818 +#: evap/staff/views.py:821 msgid "Timestamp" msgstr "Zeitstempel" -#: evap/staff/views.py:854 +#: evap/staff/views.py:857 msgid "Successfully assigned questionnaires." msgstr "Erfolgreich Fragebögen zugeordnet." -#: evap/staff/views.py:886 +#: evap/staff/views.py:889 msgid "Successfully sent reminders to everyone." msgstr "Erinnerungen an alle wurden erfolgreich versendet." -#: evap/staff/views.py:932 +#: evap/staff/views.py:935 msgid "Successfully sent reminder to {}." msgstr "Erinnerung an {} wurde erfolgreich versendet." -#: evap/staff/views.py:991 +#: evap/staff/views.py:994 msgid "Successfully created course." msgstr "Veranstaltung erfolgreich erstellt." -#: evap/staff/views.py:1010 +#: evap/staff/views.py:1013 msgid "Successfully copied course." msgstr "Veranstaltung erfolgreich kopiert." -#: evap/staff/views.py:1019 +#: evap/staff/views.py:1022 msgid "The accounts of the following contributors were reactivated:" msgstr "Die Accounts der folgenden Mitwirkenden wurden reaktiviert:" -#: evap/staff/views.py:1061 +#: evap/staff/views.py:1050 msgid "Successfully updated course." msgstr "Veranstaltung erfolgreich geändert." -#: evap/staff/views.py:1110 evap/staff/views.py:1156 +#: evap/staff/views.py:1122 evap/staff/views.py:1168 msgid "Successfully created evaluation." msgstr "Evaluierung erfolgreich erstellt." -#: evap/staff/views.py:1187 +#: evap/staff/views.py:1199 msgid "Successfully created single result." msgstr "Einzelergebnis erfolgreich erstellt." -#: evap/staff/views.py:1230 +#: evap/staff/views.py:1242 #, python-brace-format msgid "" "The removal as participant has granted the user \"{granting.user_profile." @@ -5373,67 +5399,67 @@ msgstr[1] "" "user_profile.email}\" {granting.value} Belohnungspunkte für das Semester " "vergeben." -#: evap/staff/views.py:1317 +#: evap/staff/views.py:1329 msgid "Successfully updated single result." msgstr "Einzelergebnis erfolgreich aktualisiert." -#: evap/staff/views.py:1350 +#: evap/staff/views.py:1362 msgid "Recipients: " msgstr "Empfänger·innen: " -#: evap/staff/views.py:1357 +#: evap/staff/views.py:1369 #, python-format msgid "Successfully sent emails for '%s'." msgstr "E-Mails für '%s' wurden erfolgreich versendet." -#: evap/staff/views.py:1370 +#: evap/staff/views.py:1382 msgid "{} participants were deleted from evaluation {}" msgstr "{} Teilnehmende wurden aus der Evaluierung {} entfernt" -#: evap/staff/views.py:1374 +#: evap/staff/views.py:1386 msgid "{} contributors were deleted from evaluation {}" msgstr "{} Mitwirkende wurden aus der Evaluierung {} entfernt" -#: evap/staff/views.py:1485 +#: evap/staff/views.py:1497 msgid "Login key" msgstr "Anmeldeschlüssel" -#: evap/staff/views.py:1760 evap/staff/views.py:1862 evap/staff/views.py:1907 +#: evap/staff/views.py:1772 evap/staff/views.py:1874 evap/staff/views.py:1919 msgid "Successfully created questionnaire." msgstr "Fragebogen erfolgreich erstellt." -#: evap/staff/views.py:1826 +#: evap/staff/views.py:1838 msgid "Successfully updated questionnaire." msgstr "Fragebogen erfolgreich geändert." -#: evap/staff/views.py:1882 +#: evap/staff/views.py:1894 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:1991 +#: evap/staff/views.py:2004 msgid "Successfully updated the degrees." msgstr "Studiengänge erfolgreich geändert." -#: evap/staff/views.py:2008 +#: evap/staff/views.py:2019 msgid "Successfully updated the course types." msgstr "Veranstaltungstypen erfolgreich geändert." -#: evap/staff/views.py:2036 +#: evap/staff/views.py:2044 msgid "Successfully merged course types." msgstr "Veranstaltungstypen erfolgreich zusammengeführt." -#: evap/staff/views.py:2058 +#: evap/staff/views.py:2066 msgid "Successfully updated text warning answers." msgstr "Textantwort-Warnungen erfolgreich geändert." -#: evap/staff/views.py:2125 +#: evap/staff/views.py:2133 msgid "Successfully created user." msgstr "Account erfolgreich erstellt." -#: evap/staff/views.py:2183 +#: evap/staff/views.py:2188 #, python-brace-format msgid "" "The removal of evaluations has granted the user \"{granting.user_profile." @@ -5450,19 +5476,19 @@ msgstr[1] "" "user_profile.email}\" {granting.value} Belohnungspunkte für das aktive " "Semester vergeben." -#: evap/staff/views.py:2200 +#: evap/staff/views.py:2205 msgid "Successfully updated user." msgstr "Account erfolgreich geändert." -#: evap/staff/views.py:2225 +#: evap/staff/views.py:2231 msgid "Successfully deleted user." msgstr "Account erfolgreich gelöscht." -#: evap/staff/views.py:2242 +#: evap/staff/views.py:2248 msgid "Successfully resent evaluation started email." msgstr "E-Mail über den Evaluierungsbeginn erfolgreich erneut gesendet." -#: evap/staff/views.py:2271 +#: evap/staff/views.py:2277 msgid "" "An error happened when processing the file. Make sure the file meets the " "requirements." @@ -5470,29 +5496,29 @@ msgstr "" "Ein Fehler ist bei der Verarbeitung der Datei aufgetreten. Stelle sicher, " "dass die Datei allen Anforderungen genügt." -#: evap/staff/views.py:2306 +#: evap/staff/views.py:2313 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:2308 +#: evap/staff/views.py:2315 msgid "Successfully merged users." msgstr "Accounts erfolgreich zusammengeführt." -#: evap/staff/views.py:2332 +#: evap/staff/views.py:2337 msgid "Successfully updated template." msgstr "Vorlage erfolgreich geändert." -#: evap/staff/views.py:2377 +#: evap/staff/views.py:2382 msgid "Successfully updated the FAQ sections." msgstr "FAQ-Abschnitte erfolgreich geändert." -#: evap/staff/views.py:2395 +#: evap/staff/views.py:2400 msgid "Successfully updated the FAQ questions." msgstr "FAQ-Fragen erfolgreich geändert." -#: evap/staff/views.py:2409 +#: evap/staff/views.py:2412 msgid "Successfully updated the infotext entries." msgstr "Infotexte erfolgreich geändert." @@ -5574,7 +5600,7 @@ msgstr "" "Diese Antwort wird vermutlich herausgefiltert. Du kannst das Textfeld " "einfach leer lassen." -#: evap/student/templates/student_vote.html:22 +#: evap/student/templates/student_vote.html:23 msgid "" "Please make sure to vote for all rating questions. You can also click on \"I " "can't give feedback\" to skip the questions about a single person." @@ -5583,11 +5609,15 @@ msgstr "" "Person nicht bewerten\" klicken, um die Fragen zu einer Person zu " "überspringen." -#: evap/student/templates/student_vote.html:24 +#: evap/student/templates/student_vote.html:25 +msgid "Please make sure to vote for all rating questions." +msgstr "Bitte beantworte alle Bewertungsfragen." + +#: evap/student/templates/student_vote.html:28 msgid "Jump to the first unanswered question" msgstr "Zu erster unbeantworteter Frage springen" -#: evap/student/templates/student_vote.html:31 +#: evap/student/templates/student_vote.html:35 msgid "" "The results of this evaluation will be published while the course is still " "running. This means that the contributors will receive this feedback before " @@ -5598,11 +5628,11 @@ msgstr "" "Feedback erhalten werden, bevor die abschließenden Noten für die " "Veranstaltung veröffentlicht wurden." -#: evap/student/templates/student_vote.html:35 +#: evap/student/templates/student_vote.html:39 msgid "The evaluation period will end in" msgstr "Der Evaluierungszeitraum endet in" -#: evap/student/templates/student_vote.html:37 +#: evap/student/templates/student_vote.html:41 msgid "" "Your evaluation will only be accepted if you send the completed " "questionnaire before this deadline ends." @@ -5610,11 +5640,11 @@ msgstr "" "Die Evaluierung wird nur angenommen, wenn der ausgefüllte Fragebogen vor " "Ablauf dieser Frist abgeschickt wird." -#: evap/student/templates/student_vote.html:43 +#: evap/student/templates/student_vote.html:47 msgid "Questionnaire Preview" msgstr "Fragebogen-Vorschau" -#: evap/student/templates/student_vote.html:44 +#: evap/student/templates/student_vote.html:48 msgid "" "This is a preview of the questionnaire for the evaluation. Participants will " "see the questions below." @@ -5622,12 +5652,12 @@ msgstr "" "Dies ist eine Vorschau für den Fragebogen der Evaluierung. Die Teilnehmenden " "werden die untenstehenden Fragen sehen." -#: evap/student/templates/student_vote.html:55 -#: evap/student/templates/student_vote.html:131 +#: evap/student/templates/student_vote.html:59 +#: evap/student/templates/student_vote.html:135 msgid "Small number of participants" msgstr "Geringe Anzahl an Teilnehmenden" -#: evap/student/templates/student_vote.html:58 +#: evap/student/templates/student_vote.html:62 msgid "" "Only a small number of people can take part in this evaluation. You should " "be aware that contributors might be able to guess who voted for a specific " @@ -5640,8 +5670,8 @@ msgstr "" "Ergebnisse werden nur veröffentlicht, wenn zwei oder mehr Personen an der " "Evaluierung teilgenommen haben." -#: evap/student/templates/student_vote.html:61 -#: evap/student/templates/student_vote.html:134 +#: evap/student/templates/student_vote.html:65 +#: evap/student/templates/student_vote.html:138 msgid "" "You're the first person taking part in this evaluation. If you want your " "text answers to be shown to the contributors even if you remain the only " @@ -5654,8 +5684,8 @@ msgstr "" "untenstehende Checkbox. Sonst werden deine Textantworten gelöscht, wenn " "keine weitere Person an der Evaluierung teilnimmt." -#: evap/student/templates/student_vote.html:67 -#: evap/student/templates/student_vote.html:140 +#: evap/student/templates/student_vote.html:71 +#: evap/student/templates/student_vote.html:144 msgid "" "Show my text answers to the contributors even if I remain the only person " "who takes part in this evaluation." @@ -5663,11 +5693,11 @@ msgstr "" "Zeige meine Textantworten für die Verantwortlichen an, selbst wenn ich die " "einzige Person bleibe, die an dieser Evaluierung teilnimmt." -#: evap/student/templates/student_vote.html:88 +#: evap/student/templates/student_vote.html:92 msgid "Questions about the contributors" msgstr "Fragen zu den Mitwirkenden" -#: evap/student/templates/student_vote.html:93 +#: evap/student/templates/student_vote.html:97 msgid "" "Please vote for all contributors you worked with. Click on \"I can't give " "feedback\" to skip a person." @@ -5675,7 +5705,7 @@ msgstr "" "Bitte bewerte alle Mitwirkenden oder klicke auf \"Ich kann diese Person " "nicht bewerten\", um die Person zu überspringen." -#: evap/student/templates/student_vote.html:154 +#: evap/student/templates/student_vote.html:158 msgid "" "The evaluation can be continued later using the same device and the same " "browser. But you have to submit it to send it to the server and make it " @@ -5686,11 +5716,11 @@ msgstr "" "Server geschickt und verarbeitet wird. Nach dem Absenden kannst du die " "Evaluierung nicht mehr bearbeiten." -#: evap/student/templates/student_vote.html:156 +#: evap/student/templates/student_vote.html:160 msgid "Submit questionnaire" msgstr "Fragebogen absenden" -#: evap/student/templates/student_vote.html:159 +#: evap/student/templates/student_vote.html:163 msgid "" "The server can't be reached. Your answers have been stored in your browser " "and it's safe to leave the page. Please try again later to submit the " @@ -5700,31 +5730,31 @@ msgstr "" "zwischengespeichert und du kannst die Seite nun verlassen. Bitte versuche " "später noch einmal, den Fragebogen abzusenden." -#: evap/student/templates/student_vote.html:217 +#: evap/student/templates/student_vote.html:221 msgid "just now" msgstr "gerade eben" -#: evap/student/templates/student_vote.html:219 +#: evap/student/templates/student_vote.html:223 msgid "less than 10 seconds ago" msgstr "vor weniger als 10 Sekunden" -#: evap/student/templates/student_vote.html:221 +#: evap/student/templates/student_vote.html:225 msgid "less than 30 seconds ago" msgstr "vor weniger als 30 Sekunden" -#: evap/student/templates/student_vote.html:223 +#: evap/student/templates/student_vote.html:227 msgid "less than 1 minute ago" msgstr "vor weniger als 1 Minute" -#: evap/student/templates/student_vote.html:235 +#: evap/student/templates/student_vote.html:239 msgid "Last saved locally" msgstr "Zuletzt lokal gespeichert" -#: evap/student/templates/student_vote.html:237 +#: evap/student/templates/student_vote.html:241 msgid "Could not save your information locally" msgstr "Lokales Speichern ist fehlgeschlagen" -#: evap/student/templates/student_vote.html:271 +#: evap/student/templates/student_vote.html:265 msgid "Submitting..." msgstr "Senden..." @@ -5744,6 +5774,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:252 +#: evap/student/views.py:256 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 new file mode 100644 index 000000000..356991a07 --- /dev/null +++ b/evap/locale/de/LC_MESSAGES/djangojs.po @@ -0,0 +1,29 @@ +# EvaP translation +# This file is distributed under the same license as the EvaP project. +# +msgid "" +msgstr "" +"Project-Id-Version: EvaP\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-12-18 18:39+0100\n" +"PO-Revision-Date: 2023-12-18 18:41+0100\n" +"Last-Translator: Johannes Wolf \n" +"Language-Team: Johannes Wolf (janno42)\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"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.4.1\n" + +#: evap/static/js/notebook.js:30 evap/static/ts/src/notebook.ts:39 +msgid "The server is not responding." +msgstr "Der Server reagiert nicht." + +#: evap/static/js/sortable_form.js:19 +msgid "Delete" +msgstr "Löschen" + +#: evap/static/js/sortable_form.js:20 +msgid "add another" +msgstr "Weitere·n hinzufügen" diff --git a/evap/results/exporters.py b/evap/results/exporters.py index 2cce24869..b3db18c4a 100644 --- a/evap/results/exporters.py +++ b/evap/results/exporters.py @@ -160,18 +160,22 @@ def filter_evaluations(semesters, evaluation_states, degrees, course_types, cont return evaluations_with_results, used_questionnaires, course_results_exist def write_headings_and_evaluation_info( - self, evaluations_with_results, semesters, contributor, degrees, course_types + self, evaluations_with_results, semesters, contributor, degrees, course_types, verbose_heading ): - export_name = "Evaluation" + export_name = _("Evaluation") if contributor: export_name += f"\n{contributor.full_name}" elif len(semesters) == 1: export_name += f"\n{semesters[0].name}" - degree_names = [degree.name for degree in Degree.objects.filter(pk__in=degrees)] - course_type_names = [course_type.name for course_type in CourseType.objects.filter(pk__in=course_types)] - self.write_cell( - _("{}\n\n{}\n\n{}").format(export_name, ", ".join(degree_names), ", ".join(course_type_names)), "headline" - ) + if verbose_heading: + degree_names = [degree.name for degree in Degree.objects.filter(pk__in=degrees)] + course_type_names = [course_type.name for course_type in CourseType.objects.filter(pk__in=course_types)] + self.write_cell( + f"{export_name}\n\n{', '.join(degree_names)}\n\n{', '.join(course_type_names)}", + "headline", + ) + else: + self.write_cell(export_name, "headline") for evaluation, __ in evaluations_with_results: title = evaluation.full_name @@ -285,7 +289,13 @@ def write_questionnaire(self, questionnaire, evaluations_with_results, contribut # pylint: disable=arguments-differ def export_impl( - self, semesters, selection_list, include_not_enough_voters=False, include_unpublished=False, contributor=None + self, + semesters, + selection_list, + include_not_enough_voters=False, + include_unpublished=False, + contributor=None, + verbose_heading=True, ): # We want to throw early here, since workbook.save() will throw an IndexError otherwise. assert len(selection_list) > 0 @@ -309,7 +319,7 @@ def export_impl( ) self.write_headings_and_evaluation_info( - evaluations_with_results, semesters, contributor, degrees, course_types + evaluations_with_results, semesters, contributor, degrees, course_types, verbose_heading ) for questionnaire in used_questionnaires: diff --git a/evap/results/templates/results_evaluation_detail.html b/evap/results/templates/results_evaluation_detail.html index 306102878..b371a82ab 100644 --- a/evap/results/templates/results_evaluation_detail.html +++ b/evap/results/templates/results_evaluation_detail.html @@ -26,43 +26,73 @@ {% trans 'This evaluation is private. Only contributors and participants can see the results.' %}

{% endif %} -
-

{{ evaluation.full_name }} ({{ evaluation.course.semester.name }})

+
+
+

{{ evaluation.full_name }} ({{ evaluation.course.semester.name }})

+
+ +
+
+
{% trans 'View' %}
+
diff --git a/evap/results/tests/test_views.py b/evap/results/tests/test_views.py index a1111975a..b4a6be3c9 100644 --- a/evap/results/tests/test_views.py +++ b/evap/results/tests/test_views.py @@ -471,20 +471,20 @@ def test_heading_question_filtering(self): def test_default_view_is_public(self): cache_results(self.evaluation) - # the view=public button should have class "active". The rest in-between is just noise. - expected_button = ( - f'
?' as question %} {% trans 'Delete event' as action_text %} {% include 'confirmation_modal.html' with modal_id='deleteEventModal' title=title question=question action_text=action_text btn_type='danger' %} + {% endblock %} diff --git a/evap/rewards/tools.py b/evap/rewards/tools.py index 173c8f782..142925b06 100644 --- a/evap/rewards/tools.py +++ b/evap/rewards/tools.py @@ -11,10 +11,10 @@ from evap.evaluation.models import Evaluation, Semester, UserProfile from evap.rewards.models import ( - NoPointsSelected, - NotEnoughPoints, - OutdatedRedemptionData, - RedemptionEventExpired, + NoPointsSelectedError, + NotEnoughPointsError, + OutdatedRedemptionDataError, + RedemptionEventExpiredError, RewardPointGranting, RewardPointRedemption, RewardPointRedemptionEvent, @@ -31,22 +31,22 @@ def save_redemptions(request, redemptions: dict[int, int], previous_redeemed_poi # 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() + raise OutdatedRedemptionDataError() total_points_available = reward_points_of_user(request.user) total_points_redeemed = sum(redemptions.values()) if total_points_redeemed <= 0: - raise NoPointsSelected() + raise NoPointsSelectedError() if total_points_redeemed > total_points_available: - raise NotEnoughPoints() + raise NotEnoughPointsError() 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() + raise RedemptionEventExpiredError() RewardPointRedemption.objects.create(user_profile=request.user, value=redemptions[event_id], event=event) diff --git a/evap/rewards/urls.py b/evap/rewards/urls.py index bc4c135a1..a30b73432 100644 --- a/evap/rewards/urls.py +++ b/evap/rewards/urls.py @@ -8,8 +8,8 @@ path("", views.index, name="index"), path("reward_point_redemption_events/", views.reward_point_redemption_events, name="reward_point_redemption_events"), - path("reward_point_redemption_event/create", views.reward_point_redemption_event_create, name="reward_point_redemption_event_create"), - path("reward_point_redemption_event//edit", views.reward_point_redemption_event_edit, name="reward_point_redemption_event_edit"), + path("reward_point_redemption_event/create", views.RewardPointRedemptionEventCreateView.as_view(), name="reward_point_redemption_event_create"), + path("reward_point_redemption_event//edit", views.RewardPointRedemptionEventEditView.as_view(), name="reward_point_redemption_event_edit"), path("reward_point_redemption_event//export", views.reward_point_redemption_event_export, name="reward_point_redemption_event_export"), path("reward_point_redemption_event/delete", views.reward_point_redemption_event_delete, name="reward_point_redemption_event_delete"), diff --git a/evap/rewards/views.py b/evap/rewards/views.py index 0dc58eee4..42568de6a 100644 --- a/evap/rewards/views.py +++ b/evap/rewards/views.py @@ -1,13 +1,17 @@ from datetime import datetime from django.contrib import messages +from django.contrib.messages.views import SuccessMessageMixin from django.core.exceptions import BadRequest, SuspiciousOperation from django.db.models import Sum from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse_lazy from django.utils.translation import get_language from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy from django.views.decorators.http import require_POST +from django.views.generic import CreateView, UpdateView from evap.evaluation.auth import manager_required, reward_user_required from evap.evaluation.models import Semester @@ -15,10 +19,10 @@ from evap.rewards.exporters import RewardsExporter from evap.rewards.forms import RewardPointRedemptionEventForm from evap.rewards.models import ( - NoPointsSelected, - NotEnoughPoints, - OutdatedRedemptionData, - RedemptionEventExpired, + NoPointsSelectedError, + NotEnoughPointsError, + OutdatedRedemptionDataError, + RedemptionEventExpiredError, RewardPointGranting, RewardPointRedemption, RewardPointRedemptionEvent, @@ -41,15 +45,20 @@ def redeem_reward_points(request): try: save_redemptions(request, redemptions, previous_redeemed_points) messages.success(request, _("You successfully redeemed your points.")) - except (NoPointsSelected, NotEnoughPoints, RedemptionEventExpired, OutdatedRedemptionData) as error: + except ( + NoPointsSelectedError, + NotEnoughPointsError, + RedemptionEventExpiredError, + OutdatedRedemptionDataError, + ) as error: status_code = 400 - if isinstance(error, NoPointsSelected): + if isinstance(error, NoPointsSelectedError): error_string = _("You cannot redeem 0 points.") - elif isinstance(error, NotEnoughPoints): + elif isinstance(error, NotEnoughPointsError): error_string = _("You don't have enough reward points.") - elif isinstance(error, RedemptionEventExpired): + elif isinstance(error, RedemptionEventExpiredError): error_string = _("Sorry, the deadline for this event expired already.") - elif isinstance(error, OutdatedRedemptionData): + 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." @@ -104,30 +113,23 @@ def reward_point_redemption_events(request): @manager_required -def reward_point_redemption_event_create(request): - event = RewardPointRedemptionEvent() - form = RewardPointRedemptionEventForm(request.POST or None, instance=event) - - if form.is_valid(): - form.save() - messages.success(request, _("Successfully created event.")) - return redirect("rewards:reward_point_redemption_events") - - return render(request, "rewards_reward_point_redemption_event_form.html", {"form": form}) +class RewardPointRedemptionEventCreateView(SuccessMessageMixin, CreateView): + model = RewardPointRedemptionEvent + form_class = RewardPointRedemptionEventForm + template_name = "rewards_reward_point_redemption_event_form.html" + success_url = reverse_lazy("rewards:reward_point_redemption_events") + success_message = gettext_lazy("Successfully created event.") @manager_required -def reward_point_redemption_event_edit(request, event_id): - event = get_object_or_404(RewardPointRedemptionEvent, id=event_id) - form = RewardPointRedemptionEventForm(request.POST or None, instance=event) - - if form.is_valid(): - event = form.save() - - messages.success(request, _("Successfully updated event.")) - return redirect("rewards:reward_point_redemption_events") - - return render(request, "rewards_reward_point_redemption_event_form.html", {"event": event, "form": form}) +class RewardPointRedemptionEventEditView(SuccessMessageMixin, UpdateView): + model = RewardPointRedemptionEvent + form_class = RewardPointRedemptionEventForm + template_name = "rewards_reward_point_redemption_event_form.html" + success_url = reverse_lazy("rewards:reward_point_redemption_events") + success_message = gettext_lazy("Successfully updated event.") + pk_url_kwarg = "event_id" + context_object_name = "event" @require_POST diff --git a/evap/settings.py b/evap/settings.py index 41ca1624c..9fa6190e5 100644 --- a/evap/settings.py +++ b/evap/settings.py @@ -254,6 +254,7 @@ class ManifestStaticFilesStorageWithJsReplacement(ManifestStaticFilesStorage): "django.contrib.messages.context_processors.messages", "evap.context_processors.slogan", "evap.context_processors.debug", + "evap.context_processors.notebook_form", "evap.context_processors.allow_anonymous_feedback_messages", ], "builtins": ["django.templatetags.i18n"], @@ -417,8 +418,12 @@ def CHARACTER_ALLOWED_IN_NAME(character): # pylint: disable=invalid-name TESTING = "test" in sys.argv or "pytest" in sys.modules -# speed up tests +# speed up tests and activate typeguard introspection if TESTING: + from typeguard import install_import_hook + + install_import_hook(("evap", "tools")) + # do not use ManifestStaticFilesStorage as it requires running collectstatic beforehand STORAGES["staticfiles"]["BACKEND"] = "django.contrib.staticfiles.storage.StaticFilesStorage" diff --git a/evap/staff/forms.py b/evap/staff/forms.py index e06898b3d..810feeda6 100644 --- a/evap/staff/forms.py +++ b/evap/staff/forms.py @@ -957,6 +957,7 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.user_with_same_email = None evaluations_in_active_semester = Evaluation.objects.filter(course__semester=Semester.active_semester()) excludes = [x.id for x in evaluations_in_active_semester if x.is_single_result] evaluations_in_active_semester = evaluations_in_active_semester.exclude(id__in=excludes) @@ -998,7 +999,8 @@ def clean_email(self): if self.instance and self.instance.pk: user_with_same_email = user_with_same_email.exclude(pk=self.instance.pk) - if user_with_same_email.exists(): + if user_with_same_email: + self.user_with_same_email = user_with_same_email.first() raise forms.ValidationError(_("A user with the email '%s' already exists") % email) return email @@ -1046,6 +1048,7 @@ def save(self, *args, **kw): cache_results(evaluation) self.instance.save() + return self.instance class UserMergeSelectionForm(forms.Form): @@ -1057,12 +1060,6 @@ class UserEditSelectionForm(forms.Form): user = UserModelChoiceField(UserProfile.objects.all()) -class EmailTemplateForm(forms.ModelForm): - class Meta: - model = EmailTemplate - fields = ("subject", "plain_content", "html_content") - - class FaqSectionForm(forms.ModelForm): class Meta: model = FaqSection diff --git a/evap/staff/importers/base.py b/evap/staff/importers/base.py index f5344773a..2c942ec7a 100644 --- a/evap/staff/importers/base.py +++ b/evap/staff/importers/base.py @@ -86,7 +86,7 @@ def has_errors(self) -> bool: def raise_if_has_errors(self) -> None: if self.has_errors(): - raise ImporterException(message="") + raise ImporterError(message="") def success_messages(self) -> list[ImporterLogEntry]: return self._messages_with_level_sorted_by_category(ImporterLogEntry.Level.SUCCESS) @@ -117,7 +117,7 @@ def add_success(self, message_text, *, category=ImporterLogEntry.Category.GENERA return self.add_message(ImporterLogEntry(ImporterLogEntry.Level.SUCCESS, category, message_text)) -class ImporterException(Exception): +class ImporterError(Exception): """Used to abort the import run immediately""" def __init__( @@ -145,7 +145,7 @@ def __enter__(self): pass def __exit__(self, exc_type, exc_value, traceback) -> bool: - if isinstance(exc_value, ImporterException): + if isinstance(exc_value, ImporterError): # Importers raise these to immediately abort with a message if exc_value.message: self.importer_log.add_message(exc_value.as_importer_message()) @@ -202,7 +202,7 @@ def map(self, file_content: bytes): try: book = openpyxl.load_workbook(BytesIO(file_content)) except Exception as e: - raise ImporterException( + raise ImporterError( message=_("Couldn't read the file. Error: {}").format(e), category=ImporterLogEntry.Category.SCHEMA, ) from e @@ -213,7 +213,7 @@ def map(self, file_content: bytes): continue if sheet.max_column != self.row_cls.column_count: - raise ImporterException( + raise ImporterError( message=_("Wrong number of columns in sheet '{}'. Expected: {}, actual: {}").format( sheet.title, self.row_cls.column_count, sheet.max_column ) diff --git a/evap/staff/importers/enrollment.py b/evap/staff/importers/enrollment.py index b251ac014..a3ab000d3 100644 --- a/evap/staff/importers/enrollment.py +++ b/evap/staff/importers/enrollment.py @@ -73,7 +73,14 @@ 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): +class ValidCourseDataMeta(type): + def __instancecheck__(cls, instance: object) -> TypeGuard["ValidCourseData"]: + if not isinstance(instance, CourseData): + return False + return all_fields_valid(instance) + + +class ValidCourseData(CourseData, metaclass=ValidCourseDataMeta): """Typing: CourseData instance where no element is invalid_value""" degrees: set[Degree] @@ -87,7 +94,7 @@ def all_fields_valid(course_data: CourseData) -> TypeGuard[ValidCourseData]: class DegreeImportMapper: - class InvalidDegreeNameException(Exception): + class InvalidDegreeNameError(Exception): def __init__(self, *args, invalid_degree_name: str, **kwargs): self.invalid_degree_name = invalid_degree_name super().__init__(*args, **kwargs) @@ -105,11 +112,11 @@ def degree_from_import_string(self, import_string: str) -> Degree: try: return self.degrees[lookup_key] except KeyError as e: - raise self.InvalidDegreeNameException(invalid_degree_name=trimmed_name) from e + raise self.InvalidDegreeNameError(invalid_degree_name=trimmed_name) from e class CourseTypeImportMapper: - class InvalidCourseTypeException(Exception): + class InvalidCourseTypeError(Exception): def __init__(self, *args, invalid_course_type: str, **kwargs): super().__init__(*args, **kwargs) self.invalid_course_type: str = invalid_course_type @@ -126,11 +133,11 @@ def course_type_from_import_string(self, import_string: str) -> CourseType: try: return self.course_types[stripped_name.lower()] except KeyError as e: - raise self.InvalidCourseTypeException(invalid_course_type=stripped_name) from e + raise self.InvalidCourseTypeError(invalid_course_type=stripped_name) from e class IsGradedImportMapper: - class InvalidIsGradedException(Exception): + class InvalidIsGradedError(Exception): def __init__(self, *args, invalid_is_graded: str, **kwargs): super().__init__(*args, **kwargs) self.invalid_is_graded: str = invalid_is_graded @@ -143,7 +150,7 @@ def is_graded_from_import_string(cls, is_graded: str) -> bool: if is_graded == settings.IMPORTER_GRADED_NO: return False - raise cls.InvalidIsGradedException(invalid_is_graded=is_graded) + raise cls.InvalidIsGradedError(invalid_is_graded=is_graded) @dataclass @@ -231,21 +238,21 @@ def _map_row(self, row: EnrollmentInputRow) -> EnrollmentParsedRow: degrees: MaybeInvalid[set[Degree]] try: degrees = {self.degree_mapper.degree_from_import_string(row.evaluation_degree_name)} - except DegreeImportMapper.InvalidDegreeNameException as e: + except DegreeImportMapper.InvalidDegreeNameError as e: degrees = invalid_value self.invalid_degrees_tracker.add_location_for_key(row.location, e.invalid_degree_name) course_type: MaybeInvalid[CourseType] try: course_type = self.course_type_mapper.course_type_from_import_string(row.evaluation_course_type_name) - except CourseTypeImportMapper.InvalidCourseTypeException as e: + except CourseTypeImportMapper.InvalidCourseTypeError as e: course_type = invalid_value self.invalid_course_types_tracker.add_location_for_key(row.location, e.invalid_course_type) is_graded: MaybeInvalid[bool] try: is_graded = self.is_graded_mapper.is_graded_from_import_string(row.evaluation_is_graded) - except IsGradedImportMapper.InvalidIsGradedException as e: + except IsGradedImportMapper.InvalidIsGradedError as e: is_graded = invalid_value self.invalid_is_graded_tracker.add_location_for_key(row.location, e.invalid_is_graded) @@ -305,15 +312,15 @@ def _log_aggregated_messages(self) -> None: class CourseMergeLogic: - class MergeException(Exception): + class MergeError(Exception): def __init__(self, *args, merge_hindrances: list[str], **kwargs): super().__init__(*args, **kwargs) self.merge_hindrances: list[str] = merge_hindrances - class NameDeCollisionException(Exception): + class NameDeCollisionError(Exception): """Course with same name_de, but different name_en exists""" - class NameEnCollisionException(Exception): + class NameEnCollisionError(Exception): """Course with same name_en, but different name_de exists""" def __init__(self, semester: Semester): @@ -370,10 +377,10 @@ def set_course_merge_target(self, course_data: CourseData) -> None: if course_with_same_name_en != course_with_same_name_de: if course_with_same_name_en is not None: - raise self.NameEnCollisionException() + raise self.NameEnCollisionError() if course_with_same_name_de is not None: - raise self.NameDeCollisionException() + raise self.NameDeCollisionError() assert course_with_same_name_en is not None assert course_with_same_name_de is not None @@ -383,7 +390,7 @@ def set_course_merge_target(self, course_data: CourseData) -> None: merge_hindrances = self.get_merge_hindrances(course_data, merge_candidate) if merge_hindrances: - raise self.MergeException(merge_hindrances=merge_hindrances) + raise self.MergeError(merge_hindrances=merge_hindrances) course_data.merge_into_course = merge_candidate @@ -414,15 +421,15 @@ def check_course_data(self, course_data: CourseData, location: ExcelFileLocation try: self.course_merge_logic.set_course_merge_target(course_data) - except CourseMergeLogic.MergeException as e: + except CourseMergeLogic.MergeError as e: self.course_merge_impossible_tracker.add_location_for_key( location, (course_data.name_en, tuple(e.merge_hindrances)) ) - except CourseMergeLogic.NameDeCollisionException: + except CourseMergeLogic.NameDeCollisionError: self.name_de_collision_tracker.add_location_for_key(location, course_data.name_de) - except CourseMergeLogic.NameEnCollisionException: + except CourseMergeLogic.NameEnCollisionError: self.name_en_collision_tracker.add_location_for_key(location, course_data.name_en) if course_data.merge_into_course != invalid_value and course_data.merge_into_course: diff --git a/evap/staff/templates/staff_course_type_index.html b/evap/staff/templates/staff_course_type_index.html index 9227366b0..95c7909c6 100644 --- a/evap/staff/templates/staff_course_type_index.html +++ b/evap/staff/templates/staff_course_type_index.html @@ -1,5 +1,7 @@ {% extends 'staff_base.html' %} +{% load static %} + {% block breadcrumb %} {{ block.super }} @@ -71,7 +73,7 @@ {% endblock %} {% block additional_javascript %} - {% include 'sortable_form_js.html' %} + @@ -150,11 +153,14 @@
{% trans 'To CSV file' %}
{% include 'confirmation_modal.html' with modal_id='replaceParticipantsModal' title=title question=question action_text=action_text btn_type='danger' %} @@ -164,11 +170,14 @@
{% trans 'To CSV file' %}
{% include 'confirmation_modal.html' with modal_id='copyParticipantsModal' title=title question=question action_text=action_text btn_type='primary' %} @@ -178,11 +187,14 @@
{% trans 'To CSV file' %}
{% include 'confirmation_modal.html' with modal_id='copyReplaceParticipantsModal' title=title question=question action_text=action_text btn_type='danger' %} @@ -192,11 +204,14 @@
{% trans 'To CSV file' %}
{% include 'confirmation_modal.html' with modal_id='importContributorsModal' title=title question=question action_text=action_text btn_type='primary' %} @@ -206,11 +221,14 @@
{% trans 'To CSV file' %}
{% include 'confirmation_modal.html' with modal_id='replaceContributorsModal' title=title question=question action_text=action_text btn_type='danger' %} @@ -220,11 +238,14 @@
{% trans 'To CSV file' %}
{% include 'confirmation_modal.html' with modal_id='copyContributorsModal' title=title question=question action_text=action_text btn_type='primary' %} @@ -234,11 +255,14 @@
{% trans 'To CSV file' %}
{% include 'confirmation_modal.html' with modal_id='copyReplaceContributorsModal' title=title question=question action_text=action_text btn_type='danger' %} diff --git a/evap/staff/templates/staff_evaluation_textanswers.html b/evap/staff/templates/staff_evaluation_textanswers.html index 986f42944..f2b21dbd7 100644 --- a/evap/staff/templates/staff_evaluation_textanswers.html +++ b/evap/staff/templates/staff_evaluation_textanswers.html @@ -10,23 +10,43 @@ {% block content %} {{ block.super }} -
-

{{ evaluation.full_name }} ({{ evaluation.course.semester.name }})

-
-
{% trans 'View' %}
-
- - {% trans 'Quick' %} - - - {% trans 'Full' %} - - - {% trans 'Undecided' %} - - - {% trans 'Flagged' %} - +
+
+

{{ evaluation.full_name }} ({{ evaluation.course.semester.name }})

+
+
diff --git a/evap/staff/templates/staff_evaluation_textanswers_section.html b/evap/staff/templates/staff_evaluation_textanswers_section.html index a924f5355..cb410bba1 100644 --- a/evap/staff/templates/staff_evaluation_textanswers_section.html +++ b/evap/staff/templates/staff_evaluation_textanswers_section.html @@ -8,64 +8,60 @@
{% for result in results %}

{{ result.question.text }}

- - - - - - - - - - - {% for answer in result.answers %} - - - - - - - {% endfor %} - -
{% trans 'Text answer' %}{% trans 'Decision' %}{% trans 'Flag' %}
- {{ answer.answer|linebreaksbr }} - {% if answer.original_answer %} -
- ({{ answer.original_answer|linebreaksbr }}) - {% endif %} -
- {% if user.is_manager %} - - {% endif %} - -
- {% csrf_token %} +
+
+
{% trans 'Text answer' %}
+
+
{% trans 'Decision' %}
+
{% trans 'Flag' %}
+
+ {% for answer in result.answers %} +
+
+ {{ answer.answer|linebreaksbr }} + {% if answer.original_answer %} +
+ ({{ answer.original_answer|linebreaksbr }}) + {% endif %} +
+
+ {% if user.is_manager %} + + {% endif %} +
+
+ + {% csrf_token %} - + -
- - +
+ + - - + + - - + + - - -
- -
-
- {% csrf_token %} + + + +
+ +
+
+ {% csrf_token %} - + - {% include "staff_evaluation_textanswer_flag_radios.html" with is_initially_flagged=answer.is_flagged id=answer.id %} -
-
+ {% include "staff_evaluation_textanswer_flag_radios.html" with is_initially_flagged=answer.is_flagged id=answer.id %} + +
+
+ {% endfor %} +
{% endfor %}
diff --git a/evap/staff/templates/staff_faq_index.html b/evap/staff/templates/staff_faq_index.html index f261d1c1e..93b503297 100644 --- a/evap/staff/templates/staff_faq_index.html +++ b/evap/staff/templates/staff_faq_index.html @@ -1,5 +1,7 @@ {% extends 'staff_base.html' %} +{% load static %} + {% block breadcrumb %} {{ block.super }} @@ -57,7 +59,7 @@ {% endblock %} {% block additional_javascript %} - {% include 'sortable_form_js.html' %} + {% endblock %} @@ -80,33 +81,33 @@ {% endblock %} diff --git a/evap/staff/templates/staff_questionnaire_index_list.html b/evap/staff/templates/staff_questionnaire_index_list.html index b126a5bc6..c4f0331e3 100644 --- a/evap/staff/templates/staff_questionnaire_index_list.html +++ b/evap/staff/templates/staff_questionnaire_index_list.html @@ -34,16 +34,16 @@ {% if type != 'contributor' %}
- - + +
{% endif %}
- - - + + +
diff --git a/evap/staff/templates/staff_semester_export.html b/evap/staff/templates/staff_semester_export.html index ec3e14968..912a4162b 100644 --- a/evap/staff/templates/staff_semester_export.html +++ b/evap/staff/templates/staff_semester_export.html @@ -1,6 +1,6 @@ {% extends 'staff_semester_base.html' %} -{% load evaluation_filters %} +{% load evaluation_filters static %} {% block breadcrumb %} {{ block.super }} @@ -72,7 +72,7 @@

{% trans 'Export' %} {{ semester.name }}

{% endblock %} {% block additional_javascript %} - {% include 'sortable_form_js.html' %} + diff --git a/evap/staff/templates/staff_semester_preparation_reminder.html b/evap/staff/templates/staff_semester_preparation_reminder.html index ace9e6b6b..053ec9303 100644 --- a/evap/staff/templates/staff_semester_preparation_reminder.html +++ b/evap/staff/templates/staff_semester_preparation_reminder.html @@ -78,20 +78,17 @@ {% include 'confirmation_modal.html' with modal_id='remindAllModal' title=title question=question action_text=action_text btn_type='primary' %} {% endblock %} diff --git a/evap/staff/templates/staff_semester_view.html b/evap/staff/templates/staff_semester_view.html index 444ecfd47..f586e5f83 100644 --- a/evap/staff/templates/staff_semester_view.html +++ b/evap/staff/templates/staff_semester_view.html @@ -305,11 +305,12 @@

{% if request.user.is_manager and not semester.participations_are_archived %}
@@ -449,16 +450,18 @@

{% include 'confirmation_modal.html' with modal_id='makeActiveSemesterModal' title=title question=question action_text=action_text btn_type='primary' %} {% trans 'Delete semester' as title %} @@ -467,16 +470,18 @@

{% include 'confirmation_text_modal.html' with modal_id='deleteSemesterModal' title=title question=question action_text=action_text btn_type='danger' %} {% trans 'Archive participations' as title %} @@ -485,13 +490,14 @@

{% include 'confirmation_modal.html' with modal_id='archiveParticipationsModal' title=title question=question action_text=action_text btn_type='danger' %} {% trans 'Delete grade documents' as title %} @@ -500,13 +506,14 @@

{% include 'confirmation_modal.html' with modal_id='deleteGradeDocumentsModal' title=title question=question action_text=action_text btn_type='danger' %} {% trans 'Archive results' as title %} @@ -515,13 +522,14 @@

{% include 'confirmation_modal.html' with modal_id='archiveResultsModal' title=title question=question action_text=action_text btn_type='danger' %} {% trans 'Delete evaluation' as title %} @@ -530,13 +538,14 @@

{% include 'confirmation_modal.html' with modal_id='deleteEvaluationModal' title=title question=question action_text=action_text btn_type='danger' %} {% trans 'Delete course' as title %} @@ -545,13 +554,14 @@

{% include 'confirmation_modal.html' with modal_id='deleteCourseModal' title=title question=question action_text=action_text btn_type='danger' %} {% trans 'Activate reward points' as title %} @@ -567,15 +577,6 @@

{% block additional_javascript %} {{ text_answer_warnings|text_answer_warning_trigger_strings|json_script:'text-answer-warnings' }} {% if form.instance.id %} {% trans 'Send notification email' as title %} @@ -131,13 +142,14 @@

{% trans 'Export evaluation results' %}
{% include 'confirmation_modal.html' with modal_id='resendEmailModal' title=title question=question action_text=action_text btn_type='success' %} {% endif %} diff --git a/evap/staff/templates/staff_user_import.html b/evap/staff/templates/staff_user_import.html index 6e5d8d578..b31605214 100644 --- a/evap/staff/templates/staff_user_import.html +++ b/evap/staff/templates/staff_user_import.html @@ -48,11 +48,14 @@

{% trans 'Import users' %}

{% include 'confirmation_modal.html' with modal_id='importUserModal' title=title question=question action_text=action_text btn_type='primary' %} diff --git a/evap/staff/templates/staff_user_merge_selection.html b/evap/staff/templates/staff_user_merge_selection.html index 29ccc5f73..8cccecb44 100644 --- a/evap/staff/templates/staff_user_merge_selection.html +++ b/evap/staff/templates/staff_user_merge_selection.html @@ -10,7 +10,7 @@ {{ block.super }}

{% trans 'Merge users' %}

-
+ {% csrf_token %}
diff --git a/evap/staff/tests/test_importers.py b/evap/staff/tests/test_importers.py index 30ac52728..25e5e7a66 100644 --- a/evap/staff/tests/test_importers.py +++ b/evap/staff/tests/test_importers.py @@ -10,6 +10,7 @@ import evap.staff.fixtures.excel_files_test_data as excel_data from evap.evaluation.models import Contribution, Course, CourseType, Degree, Evaluation, Semester, UserProfile +from evap.evaluation.tests.tools import assert_no_database_modifications from evap.staff.importers import ( ImporterLog, ImporterLogEntry, @@ -80,9 +81,8 @@ def setUpTestData(cls): ) def test_test_run_does_not_change_database(self): - original_users = list(UserProfile.objects.all()) - import_users(self.valid_excel_file_content, test_run=True) - self.assertEqual(original_users, list(UserProfile.objects.all())) + with assert_no_database_modifications(): + import_users(self.valid_excel_file_content, test_run=True) def test_test_and_notest_equality(self): list_test, importer_log_test = import_users(self.valid_excel_file_content, test_run=True) @@ -198,22 +198,19 @@ def test_user_data_mismatch_to_database(self): ) def test_random_file_error(self): - original_user_count = UserProfile.objects.count() - - __, importer_log_test = import_users(self.random_excel_file_content, test_run=True) - __, importer_log_notest = import_users(self.random_excel_file_content, test_run=False) + with assert_no_database_modifications(): + __, importer_log_test = import_users(self.random_excel_file_content, test_run=True) + __, importer_log_notest = import_users(self.random_excel_file_content, test_run=False) self.assertEqual(importer_log_test.errors_by_category(), importer_log_notest.errors_by_category()) self.assertErrorIs( importer_log_test, ImporterLogEntry.Category.SCHEMA, "Couldn't read the file. Error: File is not a zip file" ) - self.assertEqual(UserProfile.objects.count(), original_user_count) def test_missing_values_error(self): - original_user_count = UserProfile.objects.count() - - __, importer_log_test = import_users(self.missing_values_excel_file_content, test_run=True) - __, importer_log_notest = import_users(self.missing_values_excel_file_content, test_run=False) + with assert_no_database_modifications(): + __, importer_log_test = import_users(self.missing_values_excel_file_content, test_run=True) + __, importer_log_notest = import_users(self.missing_values_excel_file_content, test_run=False) self.assertEqual(importer_log_test.errors_by_category(), importer_log_notest.errors_by_category()) self.assertErrorsAre( @@ -226,7 +223,6 @@ def test_missing_values_error(self): ] }, ) - self.assertEqual(UserProfile.objects.count(), original_user_count) def test_import_makes_inactive_user_active(self): user = baker.make(UserProfile, email="lucilia.manilium@institution.example.com", is_active=False) @@ -258,8 +254,8 @@ def test_import_makes_inactive_user_active(self): def test_validation_error(self, mocked_validation): mocked_validation.side_effect = [None, ValidationError("TEST")] - original_user_count = UserProfile.objects.count() - user_list, importer_log = import_users(self.valid_excel_file_content, test_run=False) + with assert_no_database_modifications(): + user_list, importer_log = import_users(self.valid_excel_file_content, test_run=False) self.assertEqual(user_list, []) self.assertErrorIs( @@ -267,13 +263,13 @@ def test_validation_error(self, mocked_validation): ImporterLogEntry.Category.USER, "User bastius.quid@external.example.com: Error when validating: ['TEST']", ) - self.assertEqual(UserProfile.objects.count(), original_user_count) @override_settings(DEBUG=False) @patch("evap.evaluation.models.UserProfile.save") def test_unhandled_exception(self, mocked_db_access): mocked_db_access.side_effect = Exception("Contact your database admin right now!") - result, importer_log = import_users(self.valid_excel_file_content, test_run=False) + with assert_no_database_modifications(): + result, importer_log = import_users(self.valid_excel_file_content, test_run=False) self.assertEqual(result, []) self.assertIn( "Import aborted after exception: 'Contact your database admin right now!'. No data was imported.", @@ -281,8 +277,8 @@ def test_unhandled_exception(self, mocked_db_access): ) def test_disallow_non_string_types(self): - imported_users, importer_log = import_users(self.numerical_excel_content, test_run=False) - self.assertEqual(len(imported_users), 0) + with assert_no_database_modifications(): + _, importer_log = import_users(self.numerical_excel_content, test_run=False) self.assertErrorsAre( importer_log, @@ -295,8 +291,8 @@ def test_disallow_non_string_types(self): ) def test_wrong_column_count(self): - imported_users, importer_log = import_users(self.wrong_column_count_excel_content, test_run=False) - self.assertEqual(len(imported_users), 0) + with assert_no_database_modifications(): + _, importer_log = import_users(self.wrong_column_count_excel_content, test_run=False) self.assertErrorIs( importer_log, @@ -441,8 +437,9 @@ def test_user_degree_mismatch_error(self): excel_content = excel_data.create_memory_excel_file(import_sheets) args = (excel_content, self.semester, self.vote_start_datetime, self.vote_end_date) - importer_log_test = import_enrollments(*args, test_run=True) - importer_log_notest = import_enrollments(*args, test_run=False) + with assert_no_database_modifications(): + importer_log_test = import_enrollments(*args, test_run=True) + importer_log_notest = import_enrollments(*args, test_run=False) self.assertEqual(importer_log_test.messages, importer_log_notest.messages) self.assertErrorIs( @@ -454,9 +451,10 @@ def test_user_degree_mismatch_error(self): def test_errors_are_merged(self): """Whitebox regression test for #1711. Importers were rewritten afterwards, so this test has limited meaning now.""" excel_content = excel_data.create_memory_excel_file(excel_data.test_enrollment_data_error_merge_filedata) - importer_log = import_enrollments( - excel_content, self.semester, self.vote_start_datetime, self.vote_end_date, test_run=False - ) + with assert_no_database_modifications(): + importer_log = import_enrollments( + excel_content, self.semester, self.vote_start_datetime, self.vote_end_date, test_run=False + ) errors = [entry.message for entry in importer_log.messages if entry.level == ImporterLogEntry.Level.ERROR] @@ -519,26 +517,25 @@ def test_enrollment_importer_high_enrollment_warning(self): self.assertFalse(importer_log_notest.has_errors()) def test_random_file_error(self): - original_user_count = UserProfile.objects.count() - - importer_log_test = import_enrollments(self.random_excel_file_content, self.semester, None, None, test_run=True) - importer_log_notest = import_enrollments( - self.random_excel_file_content, self.semester, None, None, test_run=False - ) + with assert_no_database_modifications(): + importer_log_test = import_enrollments( + self.random_excel_file_content, self.semester, None, None, test_run=True + ) + importer_log_notest = import_enrollments( + self.random_excel_file_content, self.semester, None, None, test_run=False + ) self.assertEqual(importer_log_test.errors_by_category(), importer_log_notest.errors_by_category()) self.assertErrorIs( importer_log_test, ImporterLogEntry.Category.SCHEMA, "Couldn't read the file. Error: File is not a zip file" ) - self.assertEqual(UserProfile.objects.count(), original_user_count) def test_invalid_file_error(self): excel_content = excel_data.create_memory_excel_file(excel_data.invalid_enrollment_data_filedata) - original_user_count = UserProfile.objects.count() - - importer_log_test = import_enrollments(excel_content, self.semester, None, None, test_run=True) - importer_log_notest = import_enrollments(excel_content, self.semester, None, None, test_run=False) + with assert_no_database_modifications(): + importer_log_test = import_enrollments(excel_content, self.semester, None, None, test_run=True) + importer_log_notest = import_enrollments(excel_content, self.semester, None, None, test_run=False) self.assertEqual(importer_log_test.errors_by_category(), importer_log_notest.errors_by_category()) self.assertCountEqual( @@ -586,14 +583,15 @@ def test_invalid_file_error(self): ["Errors occurred while parsing the input data. No data was imported."], ) self.assertEqual(len(importer_log_test.errors_by_category()), 7) - self.assertEqual(UserProfile.objects.count(), original_user_count) def test_duplicate_course_error(self): semester = baker.make(Semester) baker.make(Course, name_de="Scheinen2", name_en="Shine", semester=semester) baker.make(Course, name_de="Stehlen", name_en="Steal2", semester=semester) - importer_log = import_enrollments(self.default_excel_content, semester, None, None, test_run=False) + with assert_no_database_modifications(): + importer_log = import_enrollments(self.default_excel_content, semester, None, None, test_run=False) + self.assertErrorsAre( importer_log, { @@ -606,7 +604,9 @@ def test_duplicate_course_error(self): def test_unknown_degree_error(self): excel_content = excel_data.create_memory_excel_file(excel_data.test_unknown_degree_error_filedata) - importer_log = import_enrollments(excel_content, baker.make(Semester), None, None, test_run=False) + + with assert_no_database_modifications(): + importer_log = import_enrollments(excel_content, self.semester, None, None, test_run=False) self.assertErrorIs( importer_log, @@ -619,7 +619,8 @@ def test_validation_error(self, mocked_validation): mocked_validation.side_effect = [None] * 5 + [ValidationError("TEST")] + [None] * 50 excel_content = excel_data.create_memory_excel_file(excel_data.test_enrollment_data_filedata) - importer_log = import_enrollments(excel_content, self.semester, None, None, test_run=False) + with assert_no_database_modifications(): + importer_log = import_enrollments(excel_content, self.semester, None, None, test_run=False) self.assertErrorIs( importer_log, @@ -705,12 +706,10 @@ def test_existing_course_different_attributes(self): existing_course.responsibles.set([baker.make(UserProfile, email="responsible_person@institution.example.com")]) existing_course.save() - old_course_count = Course.objects.count() - old_dict = model_to_dict(existing_course) - - importer_log = import_enrollments( - self.default_excel_content, self.semester, self.vote_start_datetime, self.vote_end_date, test_run=False - ) + with assert_no_database_modifications(): + 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.warnings_by_category()) self.assertErrorIs( @@ -722,20 +721,16 @@ def test_existing_course_different_attributes(self): + "
- the responsibles of the course do not match", ) - self.assertEqual(Course.objects.count(), old_course_count) - existing_course.refresh_from_db() - self.assertEqual(old_dict, model_to_dict(existing_course)) - def test_existing_course_with_published_evaluation(self): __, existing_evaluation = self.create_existing_course() # Attempt with state = Published Evaluation.objects.filter(pk=existing_evaluation.pk).update(state=Evaluation.State.PUBLISHED) - existing_evaluation = Evaluation.objects.get(pk=existing_evaluation.pk) - importer_log = import_enrollments( - self.default_excel_content, self.semester, self.vote_start_datetime, self.vote_end_date, test_run=False - ) + with assert_no_database_modifications(): + 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.warnings_by_category()) self.assertErrorIs( @@ -767,12 +762,10 @@ def test_existing_course_with_single_result(self): existing_evaluation.is_single_result = True existing_evaluation.save() - old_evaluation_count = Evaluation.objects.count() - old_dict = model_to_dict(existing_evaluation) - - importer_log = import_enrollments( - self.default_excel_content, self.semester, self.vote_start_datetime, self.vote_end_date, test_run=False - ) + with assert_no_database_modifications(): + 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.warnings_by_category()) self.assertErrorIs( @@ -783,20 +776,14 @@ def test_existing_course_with_single_result(self): + "- the evaluation of the existing course is a single result", ) - self.assertEqual(Evaluation.objects.count(), old_evaluation_count) - existing_evaluation = Evaluation.objects.get(pk=existing_evaluation.pk) - self.assertEqual(old_dict, model_to_dict(existing_evaluation)) - def test_existing_course_equal_except_evaluations(self): existing_course, __ = self.create_existing_course() baker.make(Evaluation, course=existing_course, name_de="Zweite Evaluation", name_en="Second Evaluation") - old_course_count = Course.objects.count() - old_dict = model_to_dict(existing_course) - - importer_log = import_enrollments( - self.default_excel_content, self.semester, self.vote_start_datetime, self.vote_end_date, test_run=False - ) + with assert_no_database_modifications(): + 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.warnings_by_category()) self.assertErrorIs( @@ -807,21 +794,15 @@ def test_existing_course_equal_except_evaluations(self): + "
- the existing course does not have exactly one evaluation", ) - self.assertEqual(Course.objects.count(), old_course_count) - existing_course.refresh_from_db() - self.assertEqual(old_dict, model_to_dict(existing_course)) - def test_existing_course_different_grading(self): - existing_course, existing_course_evaluation = self.create_existing_course() + _, existing_course_evaluation = self.create_existing_course() existing_course_evaluation.wait_for_grade_upload_before_publishing = False existing_course_evaluation.save() - old_course_count = Course.objects.count() - old_dict = model_to_dict(existing_course) - - importer_log = import_enrollments( - self.default_excel_content, self.semester, self.vote_start_datetime, self.vote_end_date, test_run=False - ) + with assert_no_database_modifications(): + 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.warnings_by_category()) self.assertErrorIs( @@ -831,13 +812,15 @@ def test_existing_course_different_grading(self): + "semester, but the courses can not be merged for the following reasons:" + "
- the evaluation of the existing course has a mismatching grading specification", ) - self.assertEqual(Course.objects.count(), old_course_count) - existing_course.refresh_from_db() - self.assertEqual(old_dict, model_to_dict(existing_course)) def test_wrong_column_count(self): wrong_column_count_excel_content = excel_data.create_memory_excel_file(excel_data.wrong_column_count_excel_data) - importer_log = import_enrollments(wrong_column_count_excel_content, self.semester, None, None, test_run=True) + + with assert_no_database_modifications(): + importer_log = import_enrollments( + wrong_column_count_excel_content, self.semester, None, None, test_run=True + ) + self.assertErrorIs( importer_log, ImporterLogEntry.Category.GENERAL, @@ -849,7 +832,8 @@ def test_user_data_mismatch_to_database(self): # Just check that the checker is called. It is already tested in UserImportTest.test_user_data_mismatch_to_database with patch("evap.staff.importers.user.UserDataMismatchChecker.check_userdata") as mock: - import_enrollments(excel_content, self.semester, None, None, test_run=True) + with assert_no_database_modifications(): + import_enrollments(excel_content, self.semester, None, None, test_run=True) self.assertGreater(mock.call_count, 50) def test_duplicate_participation(self): @@ -858,22 +842,18 @@ def test_duplicate_participation(self): 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) + with assert_no_database_modifications(): + importer_log = import_enrollments(excel_content, self.semester, None, None, test_run=True) + self.assertFalse(importer_log.has_errors()) 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.assertFalse(importer_log.has_errors()) 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( @@ -954,11 +934,10 @@ def setUpTestData(cls): cls.contribution2 = baker.make(Contribution, contributor=cls.contributor2, evaluation=cls.evaluation2) def test_import_existing_contributor(self): - self.assertEqual(self.evaluation1.contributions.count(), 2) - - importer_log = import_persons_from_evaluation( - ImportType.CONTRIBUTOR, self.evaluation1, test_run=True, source_evaluation=self.evaluation1 - ) + with assert_no_database_modifications(): + importer_log = import_persons_from_evaluation( + ImportType.CONTRIBUTOR, self.evaluation1, test_run=True, source_evaluation=self.evaluation1 + ) success_messages = [msg.message for msg in importer_log.success_messages()] self.assertIn("0 contributors would be added to the evaluation", "".join(success_messages)) self.assertIn( @@ -967,9 +946,12 @@ def test_import_existing_contributor(self): ) self.assertFalse(importer_log.has_errors()) + old_contributions = set(self.evaluation1.contributions.all()) importer_log = import_persons_from_evaluation( ImportType.CONTRIBUTOR, self.evaluation1, test_run=False, source_evaluation=self.evaluation1 ) + self.assertEqual(set(self.evaluation1.contributions.all()), old_contributions) + success_messages = [msg.message for msg in importer_log.success_messages()] self.assertIn("0 contributors added to the evaluation", "".join(success_messages)) self.assertIn( @@ -978,17 +960,11 @@ def test_import_existing_contributor(self): ) self.assertFalse(importer_log.has_errors()) - self.assertEqual(self.evaluation1.contributions.count(), 2) - self.assertEqual( - set(UserProfile.objects.filter(contributions__evaluation=self.evaluation1)), set([self.contributor1]) - ) - def test_import_new_contributor(self): - self.assertEqual(self.evaluation1.contributions.count(), 2) - - importer_log = import_persons_from_evaluation( - ImportType.CONTRIBUTOR, self.evaluation1, test_run=True, source_evaluation=self.evaluation2 - ) + with assert_no_database_modifications(): + importer_log = import_persons_from_evaluation( + ImportType.CONTRIBUTOR, self.evaluation1, test_run=True, source_evaluation=self.evaluation2 + ) self.assertFalse(importer_log.has_errors()) success_messages = [msg.message for msg in importer_log.success_messages()] self.assertIn("1 contributor would be added to the evaluation", "".join(success_messages)) @@ -1011,9 +987,10 @@ def test_import_new_contributor(self): ) def test_import_existing_participant(self): - importer_log = import_persons_from_evaluation( - ImportType.PARTICIPANT, self.evaluation1, test_run=True, source_evaluation=self.evaluation1 - ) + with assert_no_database_modifications(): + importer_log = import_persons_from_evaluation( + ImportType.PARTICIPANT, self.evaluation1, test_run=True, source_evaluation=self.evaluation1 + ) self.assertFalse(importer_log.has_errors()) success_messages = [msg.message for msg in importer_log.success_messages()] self.assertIn("0 participants would be added to the evaluation", "".join(success_messages)) @@ -1022,9 +999,12 @@ def test_import_existing_participant(self): [msg.message for msg in importer_log.warnings_by_category()[ImporterLogEntry.Category.GENERAL]][0], ) + old_participants = set(self.evaluation1.participants.all()) importer_log = import_persons_from_evaluation( ImportType.PARTICIPANT, self.evaluation1, test_run=False, source_evaluation=self.evaluation1 ) + self.assertEqual(set(self.evaluation1.participants.all()), old_participants) + self.assertFalse(importer_log.has_errors()) success_messages = [msg.message for msg in importer_log.success_messages()] self.assertIn("0 participants added to the evaluation", "".join(success_messages)) @@ -1033,13 +1013,11 @@ def test_import_existing_participant(self): [msg.message for msg in importer_log.warnings_by_category()[ImporterLogEntry.Category.GENERAL]][0], ) - self.assertEqual(self.evaluation1.participants.count(), 1) - self.assertEqual(self.evaluation1.participants.get(), self.participant1) - def test_import_new_participant(self): - importer_log = import_persons_from_evaluation( - ImportType.PARTICIPANT, self.evaluation1, test_run=True, source_evaluation=self.evaluation2 - ) + with assert_no_database_modifications(): + importer_log = import_persons_from_evaluation( + ImportType.PARTICIPANT, self.evaluation1, test_run=True, source_evaluation=self.evaluation2 + ) self.assertFalse(importer_log.has_errors()) success_messages = [msg.message for msg in importer_log.success_messages()] self.assertIn("1 participant would be added to the evaluation", "".join(success_messages)) @@ -1060,11 +1038,10 @@ def test_imported_participants_are_made_active(self): self.participant2.is_active = False self.participant2.save() - import_persons_from_evaluation( - ImportType.PARTICIPANT, self.evaluation1, test_run=True, source_evaluation=self.evaluation2 - ) - self.participant2.refresh_from_db() - self.assertFalse(self.participant2.is_active) + with assert_no_database_modifications(): + import_persons_from_evaluation( + ImportType.PARTICIPANT, self.evaluation1, test_run=True, source_evaluation=self.evaluation2 + ) import_persons_from_evaluation( ImportType.PARTICIPANT, self.evaluation1, test_run=False, source_evaluation=self.evaluation2 @@ -1076,11 +1053,10 @@ def test_imported_contributors_are_made_active(self): self.contributor2.is_active = False self.contributor2.save() - import_persons_from_evaluation( - ImportType.CONTRIBUTOR, self.evaluation1, test_run=True, source_evaluation=self.evaluation2 - ) - self.contributor2.refresh_from_db() - self.assertFalse(self.contributor2.is_active) + with assert_no_database_modifications(): + import_persons_from_evaluation( + ImportType.CONTRIBUTOR, self.evaluation1, test_run=True, source_evaluation=self.evaluation2 + ) import_persons_from_evaluation( ImportType.CONTRIBUTOR, self.evaluation1, test_run=False, source_evaluation=self.evaluation2 diff --git a/evap/staff/tests/test_tools.py b/evap/staff/tests/test_tools.py index 12df9b4e7..306cfa89b 100644 --- a/evap/staff/tests/test_tools.py +++ b/evap/staff/tests/test_tools.py @@ -4,6 +4,7 @@ from model_bakery import baker from evap.evaluation.models import Contribution, Course, Evaluation, UserProfile +from evap.evaluation.tests.tools import assert_no_database_modifications from evap.rewards.models import RewardPointGranting, RewardPointRedemption from evap.staff.tools import ( conditional_escape, @@ -121,51 +122,12 @@ def test_merge_handles_all_attributes(self): self.assertEqual(expected_attrs, actual_attrs) def test_merge_users_does_not_change_data_on_fail(self): - __, errors, warnings = merge_users(self.main_user, self.other_user) # merge should fail + with assert_no_database_modifications(): + __, errors, warnings = merge_users(self.main_user, self.other_user) # merge should fail + self.assertCountEqual(errors, ["contributions", "evaluations_participating_in"]) self.assertCountEqual(warnings, ["rewards"]) - # assert that nothing has changed - self.main_user.refresh_from_db() - self.other_user.refresh_from_db() - - self.assertEqual(self.main_user.title, "Dr.") - self.assertEqual(self.main_user.first_name_given, "Main") - self.assertEqual(self.main_user.first_name_chosen, "") - self.assertEqual(self.main_user.last_name, "") - self.assertEqual(self.main_user.email, None) - self.assertFalse(self.main_user.is_superuser) - self.assertEqual(set(self.main_user.groups.all()), {self.group1}) - self.assertEqual(set(self.main_user.delegates.all()), {self.user1, self.user2}) - self.assertEqual(set(self.main_user.represented_users.all()), {self.user3}) - self.assertEqual(set(self.main_user.cc_users.all()), {self.user1}) - self.assertEqual(set(self.main_user.ccing_users.all()), set()) - self.assertTrue(RewardPointGranting.objects.filter(user_profile=self.main_user).exists()) - self.assertTrue(RewardPointRedemption.objects.filter(user_profile=self.main_user).exists()) - - self.assertEqual(self.other_user.title, "") - self.assertEqual(self.other_user.first_name_given, "Other") - self.assertEqual(self.other_user.first_name_chosen, "other-display-name") - self.assertEqual(self.other_user.last_name, "User") - self.assertEqual(self.other_user.email, "other@test.com") - self.assertEqual(set(self.other_user.groups.all()), {self.group2}) - self.assertEqual(set(self.other_user.delegates.all()), {self.user3}) - self.assertEqual(set(self.other_user.represented_users.all()), {self.user1}) - self.assertEqual(set(self.other_user.cc_users.all()), set()) - self.assertEqual(set(self.other_user.ccing_users.all()), {self.user1, self.user2}) - self.assertTrue(RewardPointGranting.objects.filter(user_profile=self.other_user).exists()) - self.assertTrue(RewardPointRedemption.objects.filter(user_profile=self.other_user).exists()) - - self.assertEqual(set(self.course1.responsibles.all()), {self.main_user}) - self.assertEqual(set(self.course2.responsibles.all()), {self.main_user}) - self.assertEqual(set(self.course3.responsibles.all()), {self.other_user}) - self.assertEqual(set(self.evaluation1.participants.all()), {self.main_user, self.other_user}) - self.assertEqual(set(self.evaluation1.participants.all()), {self.main_user, self.other_user}) - self.assertEqual(set(self.evaluation2.participants.all()), {self.main_user}) - self.assertEqual(set(self.evaluation2.voters.all()), {self.main_user}) - self.assertEqual(set(self.evaluation3.participants.all()), {self.other_user}) - self.assertEqual(set(self.evaluation3.voters.all()), {self.other_user}) - def test_merge_users_changes_data_on_success(self): # Fix data so that the merge will not fail as in test_merge_users_does_not_change_data_on_fail self.evaluation1.participants.set([self.main_user]) diff --git a/evap/staff/tests/test_views.py b/evap/staff/tests/test_views.py index a90640254..4e126ac97 100644 --- a/evap/staff/tests/test_views.py +++ b/evap/staff/tests/test_views.py @@ -39,6 +39,7 @@ ) from evap.evaluation.tests.tools import ( FuzzyInt, + assert_no_database_modifications, create_evaluation_with_responsible_and_editor, let_user_vote_for_evaluation, make_manager, @@ -271,12 +272,22 @@ def setUpTestData(cls): cls.testuser = baker.make(UserProfile) cls.url = f"/staff/user/{cls.testuser.pk}/edit" - def test_questionnaire_edit(self): + def test_user_edit(self): page = self.app.get(self.url, user=self.manager, status=200) form = page.forms["user-form"] - form["email"] = "lfo9e7bmxp1xi@institution.example.com" + form["email"] = "test@institution.example.com" form.submit() - self.assertTrue(UserProfile.objects.filter(email="lfo9e7bmxp1xi@institution.example.com").exists()) + self.assertTrue(UserProfile.objects.filter(email="test@institution.example.com").exists()) + + def test_user_edit_duplicate_email(self): + second_user = baker.make(UserProfile, email="test@institution.example.com") + page = self.app.get(self.url, user=self.manager, status=200) + form = page.forms["user-form"] + form["email"] = second_user.email + page = form.submit() + self.assertContains( + page, "A user with this email address already exists. You probably want to merge the users." + ) @patch("evap.staff.forms.remove_user_from_represented_and_ccing_users") def test_inactive_edit(self, mock_remove): @@ -327,14 +338,27 @@ def get_post_params(cls): return {"user_id": cls.instance.pk} -class TestUserMergeSelectionView(WebTestStaffModeWith200Check): +class TestUserMergeSelectionView(WebTestStaffMode): url = "/staff/user/merge" @classmethod def setUpTestData(cls): - cls.test_users = [make_manager()] + cls.manager = make_manager() + + cls.main_user = baker.make(UserProfile, _fill_optional=["email"]) + cls.other_user = baker.make(UserProfile, _fill_optional=["email"]) + + def test_redirection_user_merge_view(self): + page = self.app.get(self.url, user=self.manager) + + form = page.forms["user-selection-form"] + form["main_user"] = self.main_user.pk + form["other_user"] = self.other_user.pk + + page = form.submit().follow() - baker.make(UserProfile) + self.assertContains(page, self.main_user.email) + self.assertContains(page, self.other_user.email) class TestUserMergeView(WebTestStaffModeWith200Check): @@ -1946,20 +1970,27 @@ def test_edit_course(self): self.course = Course.objects.get(pk=self.course.pk) self.assertEqual(self.course.name_en, "A different name") - @patch("evap.staff.views.redirect") - def test_operation_redirects(self, mock_redirect): - mock_redirect.side_effect = lambda *_args: HttpResponse() + @patch("evap.staff.views.reverse") + def test_operation_redirects(self, mock_reverse): + mock_reverse.return_value = "/very_legit_url" - self.prepare_form("a").submit("operation", value="save") - self.assertEqual(mock_redirect.call_args.args[0], "staff:semester_view") + response = self.prepare_form("a").submit("operation", value="save") + self.assertEqual(mock_reverse.call_args.args[0], "staff:semester_view") + self.assertRedirects(response, "/very_legit_url", fetch_redirect_response=False) - self.prepare_form("b").submit("operation", value="save_create_evaluation") - self.assertEqual(mock_redirect.call_args.args[0], "staff:evaluation_create_for_course") + response = self.prepare_form("b").submit("operation", value="save_create_evaluation") + self.assertEqual(mock_reverse.call_args.args[0], "staff:evaluation_create_for_course") + self.assertRedirects(response, "/very_legit_url", fetch_redirect_response=False) - self.prepare_form("c").submit("operation", value="save_create_single_result") - self.assertEqual(mock_redirect.call_args.args[0], "staff:single_result_create_for_course") + response = self.prepare_form("c").submit("operation", value="save_create_single_result") + self.assertEqual(mock_reverse.call_args.args[0], "staff:single_result_create_for_course") + self.assertRedirects(response, "/very_legit_url", fetch_redirect_response=False) - self.assertEqual(mock_redirect.call_count, 3) + self.assertEqual(mock_reverse.call_count, 3) + + @patch("evap.evaluation.models.Course.can_be_edited_by_manager", False) + def test_uneditable_course(self): + self.prepare_form(name_en="A different name").submit("operation", value="save", status=400) class TestCourseDeleteView(DeleteViewTestMixin, WebTestStaffMode): @@ -3085,12 +3116,9 @@ def test_invalid_parameters(self): self.app.post(self.url, user=self.manager, params=params, status=400) # invalid values - params = {self.questionnaire1.id: "asd", self.questionnaire2.id: 1} - self.app.post(self.url, user=self.manager, params=params, status=400) - - # instance not modified - self.questionnaire1.refresh_from_db() - self.assertEqual(self.questionnaire1.order, 7) + with assert_no_database_modifications(): + params = {self.questionnaire1.id: "asd", self.questionnaire2.id: 1} + self.app.post(self.url, user=self.manager, params=params, status=400) # correct parameters params = {self.questionnaire1.id: 0, self.questionnaire2.id: 1} @@ -3482,11 +3510,20 @@ 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) + def test_available_variables(self): + # We want to trigger all paths to ensure there are no syntax errors. + expected_variables = { + EmailTemplate.STUDENT_REMINDER: "first_due_in_days", + EmailTemplate.EDITOR_REVIEW_NOTICE: "evaluations", + EmailTemplate.TEXT_ANSWER_REVIEW_REMINDER: "evaluation_url_tuples", + EmailTemplate.EVALUATION_STARTED: "due_evaluations", + EmailTemplate.DIRECT_DELEGATION: "delegate_user", + } - self.assertContains(page, "evaluation_url_tuples") + for name, variable in expected_variables.items(): + template = EmailTemplate.objects.get(name=name) + page = self.app.get(f"/staff/template/{template.pk}", user=self.manager, status=200) + self.assertContains(page, variable) class TestTextAnswerWarningsView(WebTestStaffMode): diff --git a/evap/staff/tools.py b/evap/staff/tools.py index 924ab2466..07cebcf41 100644 --- a/evap/staff/tools.py +++ b/evap/staff/tools.py @@ -228,6 +228,7 @@ def merge_users(main_user, other_user, preview=False): merged_user["first_name_given"] = main_user.first_name_given or other_user.first_name_given or "" merged_user["last_name"] = main_user.last_name or other_user.last_name or "" merged_user["email"] = main_user.email or other_user.email or None + merged_user["notes"] = "\n".join((main_user.notes, other_user.notes)).strip() merged_user["groups"] = Group.objects.filter(user__in=[main_user, other_user]).distinct() merged_user["is_superuser"] = main_user.is_superuser or other_user.is_superuser diff --git a/evap/staff/urls.py b/evap/staff/urls.py index 97603cb9b..394d779cf 100644 --- a/evap/staff/urls.py +++ b/evap/staff/urls.py @@ -9,9 +9,9 @@ path("", views.index, name="index"), path("semester/", RedirectView.as_view(url='/staff/', permanent=True)), - path("semester/create", views.semester_create, name="semester_create"), + path("semester/create", views.SemesterCreateView.as_view(), name="semester_create"), path("semester/", views.semester_view, name="semester_view"), - path("semester//edit", views.semester_edit, name="semester_edit"), + path("semester//edit", views.SemesterEditView.as_view(), name="semester_edit"), path("semester/make_active", views.semester_make_active, name="semester_make_active"), path("semester/delete", views.semester_delete, name="semester_delete"), path("semester//import", views.semester_import, name="semester_import"), @@ -40,7 +40,7 @@ path("semester//course/create", views.course_create, name="course_create"), path("course/delete", views.course_delete, name="course_delete"), - path("course//edit", views.course_edit, name="course_edit"), + path("course//edit", views.CourseEditView.as_view(), name="course_edit"), path("course//copy", views.course_copy, name="course_copy"), path("semester//singleresult/create", views.single_result_create_for_semester, name="single_result_create_for_semester"), @@ -64,32 +64,32 @@ path("questionnaire/questionnaire_visibility", views.questionnaire_visibility, name="questionnaire_visibility"), path("questionnaire/questionnaire_set_locked", views.questionnaire_set_locked, name="questionnaire_set_locked"), - path("degrees/", views.degree_index, name="degree_index"), + path("degrees/", views.DegreeIndexView.as_view(), name="degree_index"), - path("course_types/", views.course_type_index, name="course_type_index"), + path("course_types/", views.CourseTypeIndexView.as_view(), name="course_type_index"), path("course_types/merge", views.course_type_merge_selection, name="course_type_merge_selection"), path("course_types//merge/", views.course_type_merge, name="course_type_merge"), path("user/", views.user_index, name="user_index"), - path("user/create", views.user_create, name="user_create"), + path("user/create", views.UserCreateView.as_view(), name="user_create"), path("user/import", views.user_import, name="user_import"), path("user//edit", views.user_edit, name="user_edit"), path("user/list", views.user_list, name="user_list"), path("user/delete", views.user_delete, name="user_delete"), path("user/resend_email", views.user_resend_email, name="user_resend_email"), path("user/bulk_update", views.user_bulk_update, name="user_bulk_update"), - path("user/merge", views.user_merge_selection, name="user_merge_selection"), + path("user/merge", views.UserMergeSelectionView.as_view(), name="user_merge_selection"), path("user//merge/", views.user_merge, name="user_merge"), path("template/", RedirectView.as_view(url='/staff/', permanent=True)), - path("template/", views.template_edit, name="template_edit"), + path("template/", views.TemplateEditView.as_view(), name="template_edit"), path("text_answer_warnings/", views.text_answer_warnings_index, name="text_answer_warnings"), - path("faq/", views.faq_index, name="faq_index"), + path("faq/", views.FaqIndexView.as_view(), name="faq_index"), path("faq/", views.faq_section, name="faq_section"), - path("infotexts/", views.infotexts, name="infotexts"), + path("infotexts/", views.InfotextsView.as_view(), name="infotexts"), path("download_sample_file/", views.download_sample_file, name="download_sample_file"), diff --git a/evap/staff/views.py b/evap/staff/views.py index d6f143a7b..cf0d6346f 100644 --- a/evap/staff/views.py +++ b/evap/staff/views.py @@ -9,20 +9,22 @@ import openpyxl from django.conf import settings from django.contrib import messages +from django.contrib.messages.views import SuccessMessageMixin from django.core.exceptions import PermissionDenied, SuspiciousOperation from django.db import IntegrityError, transaction from django.db.models import BooleanField, Case, Count, ExpressionWrapper, IntegerField, Prefetch, Q, Sum, When from django.dispatch import receiver -from django.forms import formset_factory +from django.forms import BaseForm, formset_factory from django.forms.models import inlineformset_factory, modelformset_factory from django.http import Http404, HttpRequest, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render -from django.urls import reverse +from django.urls import reverse, reverse_lazy from django.utils.html import format_html from django.utils.translation import get_language from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy, ngettext from django.views.decorators.http import require_POST +from django.views.generic import CreateView, FormView, UpdateView from django_stubs_ext import StrOrPromise from evap.contributor.views import export_contributor_results @@ -48,7 +50,9 @@ ) from evap.evaluation.tools import ( AttachmentResponse, + FormsetView, HttpResponseNoContent, + SaveValidFormMixin, get_object_from_dict_pk_entry_or_logged_40x, get_parameter_from_url_or_session, sort_formset, @@ -71,7 +75,6 @@ CourseTypeForm, CourseTypeMergeSelectionForm, DegreeForm, - EmailTemplateForm, EvaluationCopyForm, EvaluationEmailForm, EvaluationForm, @@ -571,15 +574,27 @@ def evaluation_operation(request, semester_id): @manager_required -def semester_create(request): - form = SemesterForm(request.POST or None) +class SemesterCreateView(SuccessMessageMixin, CreateView): + template_name = "staff_semester_form.html" + model = Semester + form_class = SemesterForm + success_message = gettext_lazy("Successfully created semester.") - if form.is_valid(): - semester = form.save() - messages.success(request, _("Successfully created semester.")) - return redirect("staff:semester_view", semester.id) + def get_success_url(self) -> str: + assert self.object is not None + return reverse("staff:semester_view", args=[self.object.id]) + + +@manager_required +class SemesterEditView(SuccessMessageMixin, UpdateView): + template_name = "staff_semester_form.html" + model = Semester + form_class = SemesterForm + pk_url_kwarg = "semester_id" + success_message = gettext_lazy("Successfully updated semester.") - return render(request, "staff_semester_form.html", {"form": form}) + def get_success_url(self) -> str: + return reverse("staff:semester_view", args=[self.object.id]) @require_POST @@ -595,19 +610,6 @@ def semester_make_active(request): return HttpResponse() -@manager_required -def semester_edit(request, semester_id): - semester = get_object_or_404(Semester, id=semester_id) - form = SemesterForm(request.POST or None, instance=semester) - - if form.is_valid(): - semester = form.save() - messages.success(request, _("Successfully updated semester.")) - return redirect("staff:semester_view", semester.id) - - return render(request, "staff_semester_form.html", {"semester": semester, "form": form}) - - @require_POST @manager_required def semester_delete(request): @@ -615,12 +617,13 @@ def semester_delete(request): if not semester.can_be_deleted_by_manager: raise SuspiciousOperation("Deleting semester not allowed") - RatingAnswerCounter.objects.filter(contribution__evaluation__course__semester=semester).delete() - TextAnswer.objects.filter(contribution__evaluation__course__semester=semester).delete() - Contribution.objects.filter(evaluation__course__semester=semester).delete() - Evaluation.objects.filter(course__semester=semester).delete() - Course.objects.filter(semester=semester).delete() - semester.delete() + with transaction.atomic(): + RatingAnswerCounter.objects.filter(contribution__evaluation__course__semester=semester).delete() + TextAnswer.objects.filter(contribution__evaluation__course__semester=semester).delete() + Contribution.objects.filter(evaluation__course__semester=semester).delete() + Evaluation.objects.filter(course__semester=semester).delete() + Course.objects.filter(semester=semester).delete() + semester.delete() return redirect("staff:index") @@ -1039,41 +1042,50 @@ def course_copy(request, course_id): @manager_required -def course_edit(request, course_id): - course = get_object_or_404(Course, id=course_id) +class CourseEditView(SuccessMessageMixin, UpdateView): + model = Course + pk_url_kwarg = "course_id" + form_class = CourseForm + template_name = "staff_course_form.html" + success_message = gettext_lazy("Successfully updated course.") - course_form = CourseForm(request.POST or None, instance=course) - editable = course.can_be_edited_by_manager - - if request.method == "POST" and not editable: - raise SuspiciousOperation("Modifying this course is not allowed.") + object: Course - operation = request.POST.get("operation") + def get_object(self, *args, **kwargs) -> Course: + course = super().get_object(*args, **kwargs) + if self.request.method == "POST" and not course.can_be_edited_by_manager: + raise SuspiciousOperation("Modifying this course is not allowed.") + return course - if course_form.is_valid(): - if operation not in ("save", "save_create_evaluation", "save_create_single_result"): - raise SuspiciousOperation("Invalid POST operation") + def get_context_data(self, **kwargs) -> dict[str, Any]: + context_data = super().get_context_data(**kwargs) | { + "semester": self.object.semester, + "editable": self.object.can_be_edited_by_manager, + "disable_breadcrumb_course": True, + } + context_data["course_form"] = context_data.pop("form") + return context_data - if course_form.has_changed(): - course = course_form.save() - update_template_cache_of_published_evaluations_in_course(course) + def form_valid(self, form: BaseForm) -> HttpResponse: + assert isinstance(form, CourseForm) # https://www.github.com/typeddjango/django-stubs/issues/1809 - messages.success(request, _("Successfully updated course.")) - if operation == "save_create_evaluation": - return redirect("staff:evaluation_create_for_course", course.id) - if operation == "save_create_single_result": - return redirect("staff:single_result_create_for_course", course.id) + if self.request.POST.get("operation") not in ("save", "save_create_evaluation", "save_create_single_result"): + raise SuspiciousOperation("Invalid POST operation") - return redirect("staff:semester_view", course.semester.id) + response = super().form_valid(form) + if form.has_changed(): + update_template_cache_of_published_evaluations_in_course(self.object) + return response - template_data = { - "course": course, - "semester": course.semester, - "course_form": course_form, - "editable": editable, - "disable_breadcrumb_course": True, - } - return render(request, "staff_course_form.html", template_data) + def get_success_url(self) -> str: + match self.request.POST["operation"]: + case "save": + return reverse("staff:semester_view", args=[self.object.semester.id]) + case "save_create_evaluation": + return reverse("staff:evaluation_create_for_course", args=[self.object.id]) + case "save_create_single_result": + return reverse("staff:single_result_create_for_course", args=[self.object.id]) + raise SuspiciousOperation("Unexpected operation") @require_POST @@ -1978,37 +1990,33 @@ def questionnaire_set_locked(request): @manager_required -def degree_index(request): - degrees = Degree.objects.all() - - DegreeFormset = modelformset_factory( - Degree, form=DegreeForm, formset=ModelWithImportNamesFormset, can_delete=True, extra=1 +class DegreeIndexView(SuccessMessageMixin, SaveValidFormMixin, FormsetView): + model = Degree + formset_class = modelformset_factory( + Degree, + form=DegreeForm, + formset=ModelWithImportNamesFormset, + can_delete=True, + extra=1, ) - formset = DegreeFormset(request.POST or None, queryset=degrees) - - if formset.is_valid(): - formset.save() - messages.success(request, _("Successfully updated the degrees.")) - return redirect("staff:degree_index") - - return render(request, "staff_degree_index.html", {"formset": formset}) + template_name = "staff_degree_index.html" + success_url = reverse_lazy("staff:degree_index") + success_message = gettext_lazy("Successfully updated the degrees.") @manager_required -def course_type_index(request): - course_types = CourseType.objects.all() - - CourseTypeFormset = modelformset_factory( - CourseType, form=CourseTypeForm, formset=ModelWithImportNamesFormset, can_delete=True, extra=1 +class CourseTypeIndexView(SuccessMessageMixin, SaveValidFormMixin, FormsetView): + model = CourseType + formset_class = modelformset_factory( + CourseType, + form=CourseTypeForm, + formset=ModelWithImportNamesFormset, + can_delete=True, + extra=1, ) - formset = CourseTypeFormset(request.POST or None, queryset=course_types) - - if formset.is_valid(): - formset.save() - messages.success(request, _("Successfully updated the course types.")) - return redirect("staff:course_type_index") - - return render(request, "staff_course_type_index.html", {"formset": formset}) + template_name = "staff_course_type_index.html" + success_url = reverse_lazy("staff:course_type_index") + success_message = gettext_lazy("Successfully updated the course types.") @manager_required @@ -2117,15 +2125,12 @@ def user_list(request): @manager_required -def user_create(request): - form = UserForm(request.POST or None, instance=UserProfile()) - - if form.is_valid(): - form.save() - messages.success(request, _("Successfully created user.")) - return redirect("staff:user_index") - - return render(request, "staff_user_form.html", {"form": form}) +class UserCreateView(SuccessMessageMixin, CreateView): + model = UserProfile + form_class = UserForm + template_name = "staff_user_form.html" + success_url = reverse_lazy("staff:user_index") + success_message = gettext_lazy("Successfully created user.") @manager_required @@ -2210,6 +2215,7 @@ def notify_reward_points(grantings, **_kwargs): "evaluations_contributing_to": evaluations_contributing_to, "has_due_evaluations": bool(user.get_sorted_due_evaluations()), "user_id": user_id, + "user_with_same_email": form.user_with_same_email, }, ) @@ -2284,15 +2290,16 @@ def user_bulk_update(request): @manager_required -def user_merge_selection(request): - form = UserMergeSelectionForm(request.POST or None) - - if form.is_valid(): - main_user = form.cleaned_data["main_user"] - other_user = form.cleaned_data["other_user"] - return redirect("staff:user_merge", main_user.id, other_user.id) +class UserMergeSelectionView(FormView): + form_class = UserMergeSelectionForm + template_name = "staff_user_merge_selection.html" - return render(request, "staff_user_merge_selection.html", {"form": form}) + def form_valid(self, form: UserMergeSelectionForm) -> HttpResponse: + return redirect( + "staff:user_merge", + form.cleaned_data["main_user"].id, + form.cleaned_data["other_user"].id, + ) @manager_required @@ -2323,61 +2330,59 @@ def user_merge(request, main_user_id, other_user_id): @manager_required -def template_edit(request, template_id): - template = get_object_or_404(EmailTemplate, id=template_id) - form = EmailTemplateForm(request.POST or None, request.FILES or None, instance=template) +class TemplateEditView(SuccessMessageMixin, UpdateView): + model = EmailTemplate + pk_url_kwarg = "template_id" + fields = ("subject", "plain_content", "html_content") + success_message = gettext_lazy("Successfully updated template.") + success_url = reverse_lazy("staff:index") + template_name = "staff_template_form.html" - if form.is_valid(): - form.save() - messages.success(request, _("Successfully updated template.")) - return redirect("staff:index") - - available_variables = [ - "contact_email", - "page_url", - "login_url", # only if they need it - "user", - ] + def get_context_data(self, **kwargs) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + template = context["template"] = context.pop("emailtemplate") - if template.name == EmailTemplate.STUDENT_REMINDER: - available_variables += ["first_due_in_days", "due_evaluations"] - elif template.name in [ - EmailTemplate.EDITOR_REVIEW_NOTICE, - EmailTemplate.EDITOR_REVIEW_REMINDER, - EmailTemplate.PUBLISHING_NOTICE_CONTRIBUTOR, - EmailTemplate.PUBLISHING_NOTICE_PARTICIPANT, - ]: - 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: - available_variables += ["evaluation", "delegate_user"] - - available_variables = ["{{ " + variable + " }}" for variable in available_variables] - available_variables.sort() + available_variables = [ + "contact_email", + "page_url", + "login_url", # only if they need it + "user", + ] - return render( - request, - "staff_template_form.html", - {"form": form, "template": template, "available_variables": available_variables}, - ) + if template.name == EmailTemplate.STUDENT_REMINDER: + available_variables += ["first_due_in_days", "due_evaluations"] + elif template.name in [ + EmailTemplate.EDITOR_REVIEW_NOTICE, + EmailTemplate.EDITOR_REVIEW_REMINDER, + EmailTemplate.PUBLISHING_NOTICE_CONTRIBUTOR, + EmailTemplate.PUBLISHING_NOTICE_PARTICIPANT, + ]: + 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: + available_variables += ["evaluation", "delegate_user"] + available_variables = ["{{ " + variable + " }}" for variable in available_variables] + available_variables.sort() -@manager_required -def faq_index(request): - sections = FaqSection.objects.all() + context["available_variables"] = available_variables - SectionFormset = modelformset_factory(FaqSection, form=FaqSectionForm, can_delete=True, extra=1) - formset = SectionFormset(request.POST or None, queryset=sections) + return context - if formset.is_valid(): - formset.save() - messages.success(request, _("Successfully updated the FAQ sections.")) - return redirect("staff:faq_index") - return render(request, "staff_faq_index.html", {"formset": formset, "sections": sections}) +@manager_required +class FaqIndexView(SuccessMessageMixin, SaveValidFormMixin, FormsetView): + model = FaqSection + formset_class = modelformset_factory(FaqSection, form=FaqSectionForm, can_delete=True, extra=1) + template_name = "staff_faq_index.html" + success_url = reverse_lazy("staff:faq_index") + success_message = gettext_lazy("Successfully updated the FAQ sections.") + + def get_context_data(self, **kwargs) -> dict[str, Any]: + return super().get_context_data(**kwargs) | {"sections": FaqSection.objects.all()} @manager_required @@ -2400,20 +2405,11 @@ def faq_section(request, section_id): @manager_required -def infotexts(request): - InfotextFormSet = modelformset_factory(Infotext, form=InfotextForm, edit_only=True, extra=0) - formset = InfotextFormSet(request.POST or None) - - if formset.is_valid(): - formset.save() - messages.success(request, _("Successfully updated the infotext entries.")) - return redirect("staff:infotexts") - - return render( - request, - "staff_infotexts.html", - {"formset": formset}, - ) +class InfotextsView(SuccessMessageMixin, SaveValidFormMixin, FormsetView): + formset_class = modelformset_factory(Infotext, form=InfotextForm, edit_only=True, extra=0) + template_name = "staff_infotexts.html" + success_url = reverse_lazy("staff:infotexts") + success_message = gettext_lazy("Successfully updated the infotext entries.") @manager_required diff --git a/evap/static/js/locale/select2_de.js b/evap/static/js/locale/select2_de.js deleted file mode 100644 index 2224138e3..000000000 --- a/evap/static/js/locale/select2_de.js +++ /dev/null @@ -1,3 +0,0 @@ -/*! Select2 4.0.0 | https://github.com/select2/select2/blob/master/LICENSE.md */ - -(function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;return e.define("select2/i18n/de",[],function(){return{inputTooLong:function(e){var t=e.input.length-e.maximum;return"Bitte "+t+" Zeichen weniger eingeben"},inputTooShort:function(e){var t=e.minimum-e.input.length;return"Bitte "+t+" Zeichen mehr eingeben"},loadingMore:function(){return"Lade mehr Ergebnisse…"},maximumSelected:function(e){var t="Sie können nur "+e.maximum+" Eintr";return e.maximum===1?t+="ag":t+="äge",t+=" auswählen",t},noResults:function(){return"Keine Übereinstimmungen gefunden"},searching:function(){return"Suche…"}}}),{define:e.define,require:e.require}})(); \ No newline at end of file diff --git a/evap/static/js/locale/select2_en.js b/evap/static/js/locale/select2_en.js deleted file mode 100644 index 869dfd7b2..000000000 --- a/evap/static/js/locale/select2_en.js +++ /dev/null @@ -1,3 +0,0 @@ -/*! Select2 4.0.0 | https://github.com/select2/select2/blob/master/LICENSE.md */ - -(function(){if(jQuery&&jQuery.fn&&jQuery.fn.select2&&jQuery.fn.select2.amd)var e=jQuery.fn.select2.amd;return e.define("select2/i18n/en",[],function(){return{errorLoading:function(){return"The results could not be loaded."},inputTooLong:function(e){var t=e.input.length-e.maximum,n="Please delete "+t+" character";return t!=1&&(n+="s"),n},inputTooShort:function(e){var t=e.minimum-e.input.length,n="Please enter "+t+" or more characters";return n},loadingMore:function(){return"Loading more results…"},maximumSelected:function(e){var t="You can only select "+e.maximum+" item";return e.maximum!=1&&(t+="s"),t},noResults:function(){return"No results found"},searching:function(){return"Searching…"}}}),{define:e.define,require:e.require}})(); \ No newline at end of file diff --git a/evap/static/js/locale/moment_de.js b/evap/static/js/moment_de.js similarity index 100% rename from evap/static/js/locale/moment_de.js rename to evap/static/js/moment_de.js diff --git a/evap/static/js/sortable_form.js b/evap/static/js/sortable_form.js new file mode 100644 index 000000000..2c4349b34 --- /dev/null +++ b/evap/static/js/sortable_form.js @@ -0,0 +1,60 @@ +function makeFormSortable(tableId, prefix, rowChanged, rowAdded, tolerance, removeAsButton, usesTemplate) { + + function applyOrdering() { + $(document).find('tr').each(function(i) { + if (rowChanged($(this))) { + $(this).find('input[id$=-order]').val(i); + } + else { + // if the row is empty (has no text in the input fields) set the order to -1 (default), + // so that the one extra row doesn't change its initial value + $(this).find('input[id$=-order]').val(-1); + } + }); + } + + $('#' + tableId + ' tbody tr').formset({ + prefix: prefix, + deleteCssClass: removeAsButton ? 'btn btn-danger btn-sm' : 'delete-row', + deleteText: removeAsButton ? '' : gettext('Delete'), + addText: gettext('add another'), + added: function(row) { + row.find('input[id$=-order]').val(row.parent().children().length); + + // We have to empty the formset, otherwise sometimes old contents from + // invalid forms are copied (#644). + // Checkboxes with 'data-keep' need to stay checked. + row.find("input:checkbox:not([data-keep]),:radio").removeAttr("checked"); + + row.find("input:text,textarea").val(""); + + row.find("select").each(function(){ + $(this).find('option:selected').removeAttr("selected"); + $(this).find('option').first().attr("selected", "selected"); + }); + + //Check the first item in every button group + row.find(".btn-group").each(function() { + var inputs = $(this).find("input"); + $(inputs[0]).prop("checked", true); + }); + + //Remove all error messages + row.find(".error-label").remove(); + + rowAdded(row); + }, + formTemplate: (usesTemplate ? ".form-template" : null) + }); + + new Sortable($('#' + tableId + " tbody").get(0), { + handle: ".fa-up-down", + draggable: ".sortable", + scrollSensitivity: 70 + }); + + $('form').submit(function() { + applyOrdering(); + return true; + }); +} diff --git a/evap/static/scss/_adjustments.scss b/evap/static/scss/_adjustments.scss index 8d7064816..dfd547da5 100644 --- a/evap/static/scss/_adjustments.scss +++ b/evap/static/scss/_adjustments.scss @@ -77,16 +77,6 @@ body, .fixed-top, .fixed-bottom, .is-fixed, .sticky-top { } } -// make buttons in btn-groups usable in forms -.btn-switch-navbar .btn-group > :not(:last-child) > .btn { - border-top-right-radius: 0; - border-bottom-right-radius: 0; -} -.btn-switch-navbar .btn-group > :not(:first-child) > .btn { - border-top-left-radius: 0; - border-bottom-left-radius: 0; -} - // remove horizontal line after "add another" in formsets .dynamic-form-add td { border-bottom: none; diff --git a/evap/static/scss/_components.scss b/evap/static/scss/_components.scss index bc06ea8d4..9fc73c5d1 100644 --- a/evap/static/scss/_components.scss +++ b/evap/static/scss/_components.scss @@ -19,3 +19,4 @@ @import "components/distribution-bar"; @import "components/quick-review"; +@import "components/notebook"; diff --git a/evap/static/scss/_utilities.scss b/evap/static/scss/_utilities.scss index 142ab798a..dd91f2ce3 100644 --- a/evap/static/scss/_utilities.scss +++ b/evap/static/scss/_utilities.scss @@ -67,3 +67,7 @@ a.no-underline:hover { $darker-gray 7em ); } + +.z-over-fixed { + z-index: $zindex-fixed + 1; +} diff --git a/evap/static/scss/_variables.scss b/evap/static/scss/_variables.scss index d266c2703..b069329a7 100644 --- a/evap/static/scss/_variables.scss +++ b/evap/static/scss/_variables.scss @@ -81,8 +81,11 @@ $link-decoration: none; $link-hover-decoration: underline; $small-font-size: 0.8rem; +$footer-height: 46px; $breadcrumb-divider-color: $medium-gray; $input-btn-focus-width: .25rem; $input-btn-focus-color-opacity: .25; + +$notebook-break: calc(3/2); diff --git a/evap/static/scss/components/_buttons.scss b/evap/static/scss/components/_buttons.scss index c04455691..31554a2fe 100644 --- a/evap/static/scss/components/_buttons.scss +++ b/evap/static/scss/components/_buttons.scss @@ -1,3 +1,5 @@ +@import "../bootstrap"; + .btn.fas { font-weight: 900; } @@ -108,16 +110,17 @@ a:not([href]):not(.disabled) { .btn-group { border: $darker-gray 1px solid; border-radius: 0.2rem; + overflow: hidden; - .btn-navbar { + .btn { + border: none; + border-radius: 0; padding: 0 0.25rem; background-color: tint-color($darker-gray, 10%); - border-width: 0; color: $light-gray; transition: none; min-width: 28px; margin-left: 0; - border-radius: 0.15rem; &:hover { background-color: tint-color($darker-gray, 20%); @@ -127,7 +130,6 @@ a:not([href]):not(.disabled) { &.active { background-color: tint-color($darker-gray, 10%); color: $medium-gray; - border-width: 0; box-shadow: inset 0 0 10px shade-color($darker-gray, 40%); opacity: 1; } @@ -152,6 +154,24 @@ a:not([href]):not(.disabled) { min-width: 140px; } +@media screen and (min-aspect-ratio: $notebook-break) { + .button-open-notebook { + position: fixed; + bottom: $footer-height + 20px; + z-index: $zindex-fixed; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } +} + +@media screen and (max-aspect-ratio: $notebook-break) { + .button-open-notebook { + margin-top: 1rem; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } +} + .login-button { width: 100%; max-width: 16em; diff --git a/evap/static/scss/components/_grid.scss b/evap/static/scss/components/_grid.scss index 64bcbcca9..3951dfe1f 100644 --- a/evap/static/scss/components/_grid.scss +++ b/evap/static/scss/components/_grid.scss @@ -2,6 +2,22 @@ background-color: $table-striped-bg; } +// Only needed for selecting all elements of a row +// https://stackoverflow.com/a/50734005/3984653 +.grid-row { + display: contents; +} + +.grid-striped .grid-row { + &:nth-of-type(even) > div { + background-color: $table-striped-bg; + } + + & > div { + border-bottom: 1px solid $table-border-color; + } +} + .lcr-left { flex: 1; } @@ -45,7 +61,7 @@ } } -%grid-row { +%table-grid { display: grid; min-height: 2.5rem; padding: 0.75rem; @@ -71,7 +87,7 @@ } .results-grid-row { - @extend %grid-row; + @extend %table-grid; grid: "name semester responsible participants result" @@ -122,7 +138,33 @@ } } -@each $col in name, semester, responsible, participants, result { +.textanswer-review-grid { + @extend %table-grid; + + gap: 0; + grid: + "answer edit review flag" + / auto min-content min-content min-content; + + min-height: 0; + padding: 0.5rem; + + .grid-row > div { + height: 100%; + padding: 0.5rem; + + &:not(:first-child) { + display: flex; + align-items: center; + padding-left: 0.75rem; + } + } + .grid-row:not(:first-child) > div:not(:first-child) { + justify-content: center; + } +} + +@each $col in name, semester, responsible, participants, result, answer, edit, review, flag { [data-col=#{$col}] { grid-area: $col; } diff --git a/evap/static/scss/components/_notebook.scss b/evap/static/scss/components/_notebook.scss new file mode 100644 index 000000000..e31e14326 --- /dev/null +++ b/evap/static/scss/components/_notebook.scss @@ -0,0 +1,84 @@ +.notebook { + form[data-state] { + --ready-display: none; + --sending-display: none; + --successful-display: none; + --error-display: none; + } + form[data-state="ready"] { + --ready-display: unset; + } + form[data-state="sending"] { + --sending-display: unset; + } + form[data-state="successful"] { + --successful-display: unset; + } + form[data-state="error"] { + --error-display: unset; + } + .visible-if-ready { + display: var(--ready-display); + } + .visible-if-sending { + display: var(--sending-display); + } + .visible-if-successful { + display: var(--successful-display); + } + + .notebook-animation { + transition: width .0s !important; // disable predefined bootstrap animation + } + + .right-to-element { + position: relative; + display: inline-block; + width: 0; + vertical-align: center; + } + + .notebook-textarea { + resize: none; + flex-grow: 1; + } + + @media screen and (min-aspect-ratio: $notebook-break) { + .notebook-container { + width: 25vw; + padding-left: $spacer; + padding-bottom: $spacer; + padding-top: $spacer; + } + + .notebook-card { + height: calc((100vh - #{$navbar-brand-height}) - (#{$footer-height} + ((#{$spacer} * 1.5) * 5))); // subtract 3 spacer with padding + } + } + + @media screen and (max-aspect-ratio: $notebook-break) { + .notebook-container { + width: 100vw; + z-index: $zindex-fixed; + padding: $spacer; + } + + .notebook-card { + height: calc(45vh - (#{$spacer} * 1.5) * 2); // subtract spacer with padding + min-height: 43.5vh; // approximation for size needed in smallest window (50vh - (spacer + leeway)) + } + } +} + +@media screen and (min-aspect-ratio: $notebook-break) { + .notebook-margin { + max-width: 75vw; + margin-left: 25vw; + } +} + +@media screen and (max-aspect-ratio: $notebook-break) { + .notebook-margin { + margin-top: 50vh; + } +} diff --git a/evap/static/scss/evap.scss b/evap/static/scss/evap.scss index caa6c7373..0afa6168a 100644 --- a/evap/static/scss/evap.scss +++ b/evap/static/scss/evap.scss @@ -25,7 +25,7 @@ body { position: absolute; bottom: 0; width: 100%; - height: 46px; + height: $footer-height; background: $darker-gray; line-height: 1.2; color: $light-gray; diff --git a/evap/static/ts/src/csrf-utils.ts b/evap/static/ts/src/csrf-utils.ts index f95e80320..3d82edb74 100644 --- a/evap/static/ts/src/csrf-utils.ts +++ b/evap/static/ts/src/csrf-utils.ts @@ -14,22 +14,8 @@ function getCookie(name: string): string | null { const csrftoken = getCookie("csrftoken")!; export const CSRF_HEADERS = { "X-CSRFToken": csrftoken }; -function isMethodCsrfSafe(method: string): boolean { - // these HTTP methods do not require CSRF protection - return ["GET", "HEAD", "OPTIONS", "TRACE"].includes(method); -} - -// setup ajax sending csrf token -$.ajaxSetup({ - beforeSend: function (xhr: JQuery.jqXHR, settings: JQuery.AjaxSettings) { - const isMethodSafe = settings.method && isMethodCsrfSafe(settings.method); - if (!isMethodSafe && !this.crossDomain) { - xhr.setRequestHeader("X-CSRFToken", csrftoken); - } - }, -}); +(globalThis as any).CSRF_HEADERS = CSRF_HEADERS; export const testable = { getCookie, - isMethodCsrfSafe, }; diff --git a/evap/static/ts/src/datagrid.ts b/evap/static/ts/src/datagrid.ts index e4d79b46d..bb549bcc0 100644 --- a/evap/static/ts/src/datagrid.ts +++ b/evap/static/ts/src/datagrid.ts @@ -441,7 +441,7 @@ export class ResultGrid extends DataGrid { // To store filter values independent of the language, use the corresponding id from the checkbox const values = [...row.querySelectorAll(selector)] .map(element => element.textContent!.trim()) - .map(filterName => checkboxes.find(checkbox => checkbox.dataset.filter === filterName)!.value); + .map(filterName => checkboxes.find(checkbox => checkbox.dataset.filter === filterName)?.value); filterValues.set(name, values); } return filterValues; diff --git a/evap/static/ts/src/notebook.ts b/evap/static/ts/src/notebook.ts new file mode 100644 index 000000000..c5fcf84d5 --- /dev/null +++ b/evap/static/ts/src/notebook.ts @@ -0,0 +1,88 @@ +import "./translation.js"; +import { unwrap, assert, selectOrError } from "./utils.js"; + +const NOTEBOOK_LOCALSTORAGE_KEY = "evap_notebook_open"; +const COLLAPSE_TOGGLE_BUTTON_SELECTOR = "#notebookButton"; +const WEBSITE_CONTENT_SELECTOR = "#evapContent"; +const NOTEBOOK_FORM_SELECTOR = "#notebook-form"; + +class NotebookFormLogic { + private readonly notebook: HTMLFormElement; + private readonly updateCooldown = 2000; + + constructor(notebookFormId: string) { + this.notebook = selectOrError(notebookFormId); + } + + private onSubmit = (event: SubmitEvent): void => { + event.preventDefault(); + + const submitter = unwrap(event.submitter) as HTMLButtonElement; + submitter.disabled = true; + this.notebook.setAttribute("data-state", "sending"); + + fetch(this.notebook.action, { + body: new FormData(this.notebook), + method: "POST", + }) + .then(response => { + assert(response.ok); + this.notebook.setAttribute("data-state", "successful"); + setTimeout(() => { + this.notebook.setAttribute("data-state", "ready"); + submitter.disabled = false; + }, this.updateCooldown); + }) + .catch(() => { + this.notebook.setAttribute("data-state", "ready"); + submitter.disabled = false; + alert(window.gettext("The server is not responding.")); + }); + }; + + public attach = (): void => { + this.notebook.addEventListener("submit", this.onSubmit); + }; +} + +export class NotebookLogic { + private readonly notebookCard: HTMLElement; + private readonly evapContent: HTMLElement; + private readonly formLogic: NotebookFormLogic; + private readonly localStorageKey: string; + + constructor(notebookSelector: string) { + this.notebookCard = selectOrError(notebookSelector); + this.formLogic = new NotebookFormLogic(NOTEBOOK_FORM_SELECTOR); + this.evapContent = selectOrError(WEBSITE_CONTENT_SELECTOR); + this.localStorageKey = NOTEBOOK_LOCALSTORAGE_KEY + "_" + notebookSelector; + } + + private onShowNotebook = (): void => { + this.notebookCard.classList.add("notebook-container"); + + localStorage.setItem(this.localStorageKey, "true"); + this.evapContent.classList.add("notebook-margin"); + selectOrError(COLLAPSE_TOGGLE_BUTTON_SELECTOR).classList.replace("show", "hide"); + }; + + private onHideNotebook = (): void => { + this.notebookCard.classList.remove("notebook-container"); + + localStorage.setItem(this.localStorageKey, "false"); + this.evapContent.classList.remove("notebook-margin"); + selectOrError(COLLAPSE_TOGGLE_BUTTON_SELECTOR).classList.replace("hide", "show"); + }; + + public attach = (): void => { + if (localStorage.getItem(this.localStorageKey) == "true") { + this.notebookCard.classList.add("show"); + this.onShowNotebook(); + } + + this.notebookCard.addEventListener("show.bs.collapse", this.onShowNotebook); + this.notebookCard.addEventListener("hidden.bs.collapse", this.onHideNotebook); + + this.formLogic.attach(); + }; +} diff --git a/evap/static/ts/src/translation.ts b/evap/static/ts/src/translation.ts new file mode 100644 index 000000000..b56bbcc83 --- /dev/null +++ b/evap/static/ts/src/translation.ts @@ -0,0 +1,16 @@ +// Django's own JavaScript translation catalog is provided as global JS +// In order to make it usable with TypeScript, additional type definitons +// are necessary: +export {}; +declare global { + interface Window { + gettext(msgid: string): string; + pluralidx(n: number | boolean): number; + ngettext(singular: string, plural: string, count: number): string; + gettext_noop(msgid: string): string; + pgettext(context: string, msgid: string): string; + npgettext(context: string, singular: string, plural: string, count: number): string; + interpolate(fmt: string, obj: any, named: boolean): string; + get_format(format_type: string): string; + } +} diff --git a/evap/static/ts/src/utils.ts b/evap/static/ts/src/utils.ts index 533d08d13..9a9f61b2a 100644 --- a/evap/static/ts/src/utils.ts +++ b/evap/static/ts/src/utils.ts @@ -38,4 +38,20 @@ export const findPreviousElementSibling = (element: Element, selector: string): return null; }; +export function unwrap(val: T): NonNullable { + assertDefined(val); + return val; +} + export const isVisible = (element: HTMLElement): boolean => element.offsetWidth !== 0 || element.offsetHeight !== 0; + +export const fadeOutThenRemove = (element: HTMLElement) => { + element.style.transition = "opacity 600ms"; + element.style.opacity = "0"; + setTimeout(() => { + element.remove(); + }, 600); +}; + +(globalThis as any).assert = assert; +(globalThis as any).fadeOutThenRemove = fadeOutThenRemove; diff --git a/evap/static/ts/tests/unit/csrf-utils.ts b/evap/static/ts/tests/unit/csrf-utils.ts index 6e5085c40..7ed01095f 100644 --- a/evap/static/ts/tests/unit/csrf-utils.ts +++ b/evap/static/ts/tests/unit/csrf-utils.ts @@ -10,12 +10,9 @@ Object.defineProperty(document, "cookie", { `baz=${encodeURIComponent("+{`")}`, }); -// @ts-ignore -window.$ = require("../../../js/jquery-2.1.3.min"); - import { testable } from "src/csrf-utils"; -const { getCookie, isMethodCsrfSafe } = testable; +const { getCookie } = testable; test("parse cookie", () => { expect(getCookie("foo")).toBe("F00"); @@ -24,24 +21,3 @@ test("parse cookie", () => { expect(getCookie("csrftoken")).toBe("token"); expect(getCookie("qux")).toBe(null); }); - -test.each(["GET", "HEAD", "OPTIONS", "TRACE"])("method %s is considered safe", method => { - expect(isMethodCsrfSafe(method)).toBe(true); -}); - -test.each(["POST", "PUT", "DELETE"])("method %s is considered unsafe", method => { - expect(isMethodCsrfSafe(method)).toBe(false); -}); - -test("send csrf token in request", () => { - const mock = { - open: jest.fn(), - send: jest.fn(), - setRequestHeader: jest.fn(), - }; - window.XMLHttpRequest = jest.fn(() => mock) as unknown as typeof window.XMLHttpRequest; - - $.post("/receiver"); - - expect(mock.setRequestHeader).toBeCalledWith("X-CSRFToken", "token"); -}); diff --git a/evap/static/ts/tests/utils/page.ts b/evap/static/ts/tests/utils/page.ts index 60a327355..0cb49c205 100644 --- a/evap/static/ts/tests/utils/page.ts +++ b/evap/static/ts/tests/utils/page.ts @@ -20,14 +20,26 @@ async function createPage(browser: Browser): Promise { const extension = path.extname(request.url()); const pathname = new URL(request.url()).pathname; if (extension === ".html") { + // requests like /evap/evap/static/ts/rendered/results/student.html request.continue(); } else if (pathname.startsWith(staticPrefix)) { + // requests like /static/css/tom-select.bootstrap5.min.css const asset = pathname.substr(staticPrefix.length); const body = fs.readFileSync(path.join(__dirname, "..", "..", "..", asset)); request.respond({ contentType: contentTypeByExtension.get(extension), body, }); + } else if (pathname.endsWith("catalog.js")) { + // request for /catalog.js + // some pages will error out if translation functions are not available + // rendered in RenderJsTranslationCatalog + const absolute_fs_path = path.join(__dirname, "..", "..", "..", "ts", "rendered", "catalog.js"); + const body = fs.readFileSync(absolute_fs_path); + request.respond({ + contentType: contentTypeByExtension.get(extension), + body, + }); } else { request.abort(); } diff --git a/evap/student/templates/student_vote.html b/evap/student/templates/student_vote.html index 681bc8a45..30411076c 100644 --- a/evap/student/templates/student_vote.html +++ b/evap/student/templates/student_vote.html @@ -19,7 +19,11 @@