diff --git a/evap/contributor/forms.py b/evap/contributor/forms.py index c6640371b4..e80cebc9c9 100644 --- a/evap/contributor/forms.py +++ b/evap/contributor/forms.py @@ -1,11 +1,15 @@ from datetime import datetime from django import forms -from django.db.models import Q +from django.db.models import Q, QuerySet from django.forms.widgets import CheckboxSelectMultiple +from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from evap.evaluation.forms import UserModelChoiceField, UserModelMultipleChoiceField +from evap.evaluation.forms import ( + UserModelChoiceField, + UserModelMultipleChoiceField, +) from evap.evaluation.models import Course, Evaluation, Questionnaire, UserProfile from evap.evaluation.tools import vote_end_datetime from evap.staff.forms import ContributionForm @@ -69,10 +73,8 @@ def __init__(self, *args, **kwargs): self.fields["vote_start_datetime"].localize = True self.fields["vote_end_date"].localize = True - queryset = UserProfile.objects.exclude(is_active=False) - if self.instance.pk is not None: - queryset = (queryset | self.instance.participants.all()).distinct() - self.fields["participants"].queryset = queryset + self.fields["participants"].queryset = self.get_participants_queryset(self.instance) + self.fields["participants"].widget.options_endpoint = reverse("contributor:participant_options") if general_contribution := self.instance.general_contribution: self.fields["general_questionnaires"].initial = [ @@ -90,6 +92,13 @@ def __init__(self, *args, **kwargs): self.fields["participants"].disabled = True self.cms_disclaimer = _("Participants are regularly updated with registrations from the CMS.") + @classmethod + def get_participants_queryset(cls, evaluation: Evaluation | None) -> QuerySet[UserProfile]: + queryset = UserProfile.objects.exclude(is_active=False) + if evaluation is not None and evaluation.pk is not None: + queryset = (queryset | evaluation.participants.all()).distinct() + return queryset + def clean(self): super().clean() @@ -157,6 +166,8 @@ def __init__(self, *args, **kwargs): class DelegateSelectionForm(forms.Form): - delegate_to = UserModelChoiceField( - label=_("Delegate to"), queryset=UserProfile.objects.exclude(is_active=False).exclude(is_proxy_user=True) - ) + delegate_to = UserModelChoiceField(label=_("Delegate to"), queryset=UserProfile.objects.get_delegate_options()) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["delegate_to"].widget.options_endpoint = reverse("contributor:delegate_options") diff --git a/evap/contributor/tests/test_live.py b/evap/contributor/tests/test_live.py index c2ad7182d5..94241b9385 100644 --- a/evap/contributor/tests/test_live.py +++ b/evap/contributor/tests/test_live.py @@ -4,7 +4,7 @@ from selenium.webdriver.common.by import By from evap.evaluation.models import Contribution, Course, Evaluation, Program, UserProfile -from evap.evaluation.tests.tools import LiveServerTest +from evap.evaluation.tests.tools import LiveServerTest, UserProfileSearchLiveServerTest class ContributorDelegationLiveTest(LiveServerTest): @@ -19,11 +19,7 @@ def test_delegation_modal(self): vote_start_datetime=datetime(2099, 1, 1, 0, 0), vote_end_date=date(2099, 12, 31), ) - self.assertFalse( - Contribution.objects.filter( - evaluation=evaluation, contributor__email="manager@institution.example.com" - ).exists() - ) + self.assertFalse(Contribution.objects.filter(evaluation=evaluation, contributor=self.manager).exists()) self.assertEqual(evaluation.contributions.count(), 1) self.selenium.get(self.reverse("contributor:index")) @@ -32,20 +28,80 @@ def test_delegation_modal(self): ) delegate_button.click() - open_dropdown_field = self.selenium.find_element(By.CSS_SELECTOR, "input[placeholder='Please select...']") - open_dropdown_field.click() - - first_option = self.selenium.find_element( - By.XPATH, "//div[contains(@class, 'option') and contains(text(), 'manager')]" - ) - first_option.click() + self.search_and_select_in_tom_select("delegate_to", self.manager.email) submit_button = self.selenium.find_element(By.CSS_SELECTOR, "span[slot='action-text']") submit_button.click() self.assertEqual(evaluation.contributions.count(), 2) - self.assertTrue( - Contribution.objects.filter( - evaluation=evaluation, contributor__email="manager@institution.example.com" - ).exists() + self.assertTrue(Contribution.objects.filter(evaluation=evaluation, contributor=self.manager).exists()) + + +class ContributorUserProfileSearchLiveTest(UserProfileSearchLiveServerTest): + def test_delegation_modal_set_delegate_to(self) -> None: + """Test delegate_to field in DelegateSelectionForm.""" + possible_delegate = baker.make( + UserProfile, first_name_given="Jane", last_name="Doe", email="jane.doe@institution.example.com" + ) + not_active_user = baker.make( + UserProfile, + first_name_given="User 1", + last_name="User 1", + email="user1@institution.example.com", + is_active=False, + ) + is_proxy_user = baker.make( + UserProfile, + first_name_given="User 2", + last_name="User 2", + email="user2@institution.example.com", + is_proxy_user=True, + ) + + responsible = baker.make(UserProfile) + self.login(responsible) + + baker.make( + Evaluation, + course=baker.make(Course, programs=[baker.make(Program)], responsibles=[responsible]), + state=Evaluation.State.PREPARED, + vote_start_datetime=datetime(2099, 1, 1, 0, 0), + vote_end_date=date(2099, 12, 31), + ) + + self.selenium.get(self.reverse("contributor:index")) + + delegate_button = self.selenium.find_element( + By.CSS_SELECTOR, "confirmation-modal button[data-bs-original-title='Delegate preparation']" + ) + delegate_button.click() + + self.conduct_user_profile_search_test("delegate_to", [possible_delegate], [not_active_user, is_proxy_user]) + + def test_evaluation_form_set_participants(self) -> None: + """Test participants field in EvaluationForm.""" + possible_participant = baker.make( + UserProfile, first_name_given="Jane", last_name="Doe", email="jane.doe@institution.example.com" + ) + not_active_user = baker.make( + UserProfile, + first_name_given="User 1", + last_name="User 1", + email="user1@institution.example.com", + is_active=False, + ) + + responsible = baker.make(UserProfile) + self.login(responsible) + + evaluation = baker.make( + Evaluation, + course=baker.make(Course, programs=[baker.make(Program)], responsibles=[responsible]), + state=Evaluation.State.PREPARED, + vote_start_datetime=datetime(2099, 1, 1, 0, 0), + vote_end_date=date(2099, 12, 31), ) + + self.selenium.get(self.reverse("contributor:evaluation_edit", args=[evaluation.pk])) + + self.conduct_user_profile_search_test("participants", [possible_participant], [not_active_user]) diff --git a/evap/contributor/urls.py b/evap/contributor/urls.py index e0aa6875c1..94e2d93532 100644 --- a/evap/contributor/urls.py +++ b/evap/contributor/urls.py @@ -10,5 +10,9 @@ path("evaluation/", views.evaluation_view, name="evaluation_view"), path("evaluation//edit", views.evaluation_edit, name="evaluation_edit"), path("evaluation//preview", views.evaluation_preview, name="evaluation_preview"), - path("evaluation//direct_delegation", views.evaluation_direct_delegation, name="evaluation_direct_delegation") + path("evaluation//direct_delegation", views.evaluation_direct_delegation, name="evaluation_direct_delegation"), + path("user_profiles/delegates/", views.DelegateOptionsView.as_view(), + name="delegate_options"), + path("user_profiles/participants/", views.ParticipantOptionsView.as_view(), + name="participant_options"), ] diff --git a/evap/contributor/views.py b/evap/contributor/views.py index aa62b74576..df20c3a41d 100644 --- a/evap/contributor/views.py +++ b/evap/contributor/views.py @@ -1,7 +1,7 @@ from django.contrib import messages from django.core.exceptions import PermissionDenied, SuspiciousOperation from django.db import IntegrityError, transaction -from django.db.models import Exists, Max, OuterRef, Q +from django.db.models import Exists, Max, OuterRef, Q, QuerySet from django.forms.models import inlineformset_factory from django.shortcuts import get_object_or_404, redirect, render from django.utils.safestring import mark_safe @@ -26,6 +26,7 @@ get_object_from_dict_pk_entry_or_logged_40x, sort_formset, ) +from evap.evaluation.views import UserProfileOptionsBaseView from evap.results.exporters import ResultsExporter from evap.results.tools import annotate_distributions_and_grades, get_evaluations_with_course_result_attributes from evap.staff.forms import ContributionFormset @@ -304,3 +305,22 @@ def export_contributor_results(contributor): @responsible_or_contributor_or_delegate_required def export(request): return export_contributor_results(request.user) + + +@responsible_or_contributor_or_delegate_required +class DelegateOptionsView(UserProfileOptionsBaseView): + @classmethod + def get_queryset(cls, request, *args, **kwargs) -> QuerySet[UserProfile]: + return super().get_queryset(request, *args, **kwargs).filter(pk__in=UserProfile.objects.get_delegate_options()) + + +@responsible_or_contributor_or_delegate_required +class ParticipantOptionsView(UserProfileOptionsBaseView): + @classmethod + def get_queryset(cls, request, *args, **kwargs) -> QuerySet[UserProfile]: + evaluation = get_object_or_404(Evaluation, id=kwargs["evaluation"]) if kwargs.get("evaluation") else None + return ( + super() + .get_queryset(request, *args, **kwargs) + .filter(pk__in=EvaluationForm.get_participants_queryset(evaluation)) + ) diff --git a/evap/evaluation/forms.py b/evap/evaluation/forms.py index adeb8638a1..985c2f4111 100644 --- a/evap/evaluation/forms.py +++ b/evap/evaluation/forms.py @@ -5,6 +5,7 @@ from django.contrib.auth import authenticate from django.core.exceptions import ValidationError from django.http import HttpRequest +from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django.views.decorators.debug import sensitive_variables @@ -14,6 +15,27 @@ logger = logging.getLogger(__name__) +class ServerSideOptionsSelectWidget(forms.Select): + template_name = "django/forms/widgets/server_side_options_select.html" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if kwargs.get("options_endpoint"): + self.options_endpoint = kwargs["options_endpoint"] + + @property + def options_endpoint(self): + return self.attrs["data-tomselect-server-side-options-endpoint"] + + @options_endpoint.setter + def options_endpoint(self, value): + self.attrs["data-tomselect-server-side-options-endpoint"] = value + + +class ServerSideOptionsSelectMultipleWidget(ServerSideOptionsSelectWidget, forms.SelectMultiple): + pass + + class LoginEmailForm(forms.Form): """Form encapsulating the login with email and password, for example from an Active Directory.""" @@ -96,12 +118,14 @@ def get_user(self) -> UserProfile | None: class UserModelChoiceField(forms.ModelChoiceField): + widget = ServerSideOptionsSelectWidget() + def label_from_instance(self, obj: UserProfile) -> str: return obj.full_name_with_additional_info class UserModelMultipleChoiceField(forms.ModelMultipleChoiceField): - widget = forms.SelectMultiple(attrs={"data-tomselect-fullwidth": ""}) + widget = ServerSideOptionsSelectMultipleWidget(attrs={"data-tomselect-fullwidth": ""}) def label_from_instance(self, obj: UserProfile) -> str: return obj.full_name_with_additional_info @@ -109,20 +133,19 @@ def label_from_instance(self, obj: UserProfile) -> str: class ProfileForm(forms.ModelForm): delegates = UserModelMultipleChoiceField( - queryset=UserProfile.objects.exclude(is_active=False).exclude(is_proxy_user=True), required=False + queryset=UserProfile.objects.get_delegate_options(), + required=False, ) class Meta: model = UserProfile fields = ("title", "first_name_chosen", "first_name_given", "last_name", "email", "delegates") - field_classes = { - "delegates": UserModelMultipleChoiceField, - } def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) for field in ("title", "first_name_given", "last_name", "email"): self.fields[field].disabled = True + self.fields["delegates"].widget.options_endpoint = reverse("contributor:delegate_options") def save(self, *args, **kwargs) -> None: super().save(*args, **kwargs) diff --git a/evap/evaluation/migrations/0165_trigram.py b/evap/evaluation/migrations/0165_trigram.py new file mode 100644 index 0000000000..226e8809e9 --- /dev/null +++ b/evap/evaluation/migrations/0165_trigram.py @@ -0,0 +1,10 @@ +from django.contrib.postgres.operations import TrigramExtension +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("evaluation", "0164_remove_questionnaire_questionnaire_visibility_choices_and_more"), + ] + + operations = [TrigramExtension()] diff --git a/evap/evaluation/models.py b/evap/evaluation/models.py index 3be4f8a095..1558b821e3 100644 --- a/evap/evaluation/models.py +++ b/evap/evaluation/models.py @@ -1822,6 +1822,9 @@ def create_superuser(self, *, email, password=None, first_name_given=None, last_ user.groups.add(Group.objects.get(name="Manager")) return user + def get_delegate_options(self): + return self.exclude(is_active=False).exclude(is_proxy_user=True) + assert settings.AUTH_PASSWORD_VALIDATORS == [], "Password validation configured, but evap will not apply it" diff --git a/evap/evaluation/templates/base.html b/evap/evaluation/templates/base.html index c1e3309182..d1660785f3 100644 --- a/evap/evaluation/templates/base.html +++ b/evap/evaluation/templates/base.html @@ -148,7 +148,7 @@ applyTomSelect = function(elements, additionalOptions = {}) { elements.forEach((element) => { - const minimumInputLength = element.options.length >= 50 ? 3 : 0; + const minimumInputLength = (element.options.length >= 50 || element.hasAttribute("data-tomselect-server-side-options-endpoint")) ? 3 : 0; element.tomselect?.destroy(); element.classList.remove("form-select"); // TomSelect applies their own matching classes / styles @@ -159,7 +159,7 @@ const baseOptions = { createOnBlur: true, placeholder: "{% translate 'Please select...' %}", - hidePlaceholder: element.hasAttribute("data-tomselect-fullwidth") ? false : true, + hidePlaceholder: !element.hasAttribute("data-tomselect-fullwidth"), minimumInputLength, render: { option_create: (data, escape) => `
${ escape(data.input) }
`, @@ -169,8 +169,22 @@ } else { return '
{% translate "No results found" %}
'; } - } + }, + not_loading: (data, escape) => `
{% translate "Please enter ${ minimumInputLength } characters or more..." %}
` }, + ...(element.hasAttribute("data-tomselect-server-side-options-endpoint") ? { + shouldLoad: (query) => query.length >= minimumInputLength, + load: (query, callback) => { + const url = element.getAttribute("data-tomselect-server-side-options-endpoint") + "?query=" + encodeURIComponent(query); + fetch(url) + .then(response => response.json()) + .then(json => callback(json.options)) + .catch(() => alert(window.gettext("The server is not responding."))); + }, + valueField: 'id', + labelField: 'text', + searchField: 'text' + } : {}), closeAfterSelect: true, // also clears search input on enter. plugins: {}, hideSelected: false, @@ -217,7 +231,7 @@ const element = document.getElementById(id); element.innerHTML = ""; element.className = "fas fa-spinner fa-spin"; // clears all other classes - }; + } document.querySelectorAll("[data-set-spinner-icon]").forEach(el => { el.addEventListener("click", () => setSpinnerIcon(el.dataset.setSpinnerIcon)); diff --git a/evap/evaluation/templates/django/forms/widgets/server_side_options_select.html b/evap/evaluation/templates/django/forms/widgets/server_side_options_select.html new file mode 100644 index 0000000000..141fc43e85 --- /dev/null +++ b/evap/evaluation/templates/django/forms/widgets/server_side_options_select.html @@ -0,0 +1,12 @@ + diff --git a/evap/evaluation/tests/test_live.py b/evap/evaluation/tests/test_live.py index e789091268..75a21c4b9d 100644 --- a/evap/evaluation/tests/test_live.py +++ b/evap/evaluation/tests/test_live.py @@ -1,8 +1,10 @@ from django.core import mail +from model_bakery import baker from selenium.webdriver.common.by import By from selenium.webdriver.support.expected_conditions import text_to_be_present_in_element, visibility_of_element_located -from evap.evaluation.tests.tools import LiveServerTest +from evap.evaluation.models import Course, UserProfile +from evap.evaluation.tests.tools import LiveServerTest, UserProfileSearchLiveServerTest class ContactModalTests(LiveServerTest): @@ -22,3 +24,33 @@ def test_contact_modal(self) -> None: self.assertEqual(len(mail.outbox), 1) self.assertEqual(mail.outbox[0].subject, f"[EvaP] Message from {self.manager.email}") + + +class ProfileUserProfileSearchTest(UserProfileSearchLiveServerTest): + def test_profile_set_delegates(self) -> None: + """Test delegates field in ProfileForm.""" + possible_delegate = baker.make( + UserProfile, first_name_given="Jane", last_name="Doe", email="test@institution.example.com" + ) + not_active_user = baker.make( + UserProfile, + first_name_given="User 1", + last_name="User 1", + email="user1@institution.example.com", + is_active=False, + ) + is_proxy_user = baker.make( + UserProfile, + first_name_given="User 2", + last_name="User 2", + email="user2@institution.example.com", + is_proxy_user=True, + ) + + active_user = baker.make(UserProfile) + baker.make(Course, responsibles=[active_user]) + + self.login(active_user) + self.selenium.get(self.reverse("evaluation:profile_edit")) + + self.conduct_user_profile_search_test("delegates", [possible_delegate], [not_active_user, is_proxy_user]) diff --git a/evap/evaluation/tests/test_views.py b/evap/evaluation/tests/test_views.py index 2eed431acf..4474c55f5c 100644 --- a/evap/evaluation/tests/test_views.py +++ b/evap/evaluation/tests/test_views.py @@ -161,7 +161,7 @@ def test_save_settings(self): user = baker.make(UserProfile) page = self.app.get(self.url, user=self.responsible) form = page.forms["profile-form"] - form["delegates"] = [user.pk] + form["delegates"].force_value([user.pk]) form.submit() self.responsible.refresh_from_db() diff --git a/evap/evaluation/tests/tools.py b/evap/evaluation/tests/tools.py index 5fd6de2e57..0efd4f1792 100644 --- a/evap/evaluation/tests/tools.py +++ b/evap/evaluation/tests/tools.py @@ -23,9 +23,13 @@ from django.utils import timezone, translation from model_bakery import baker from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys from selenium.webdriver.firefox.webdriver import WebDriver from selenium.webdriver.remote.webelement import WebElement -from selenium.webdriver.support.expected_conditions import staleness_of +from selenium.webdriver.support.expected_conditions import ( + presence_of_element_located, + staleness_of, +) from selenium.webdriver.support.wait import WebDriverWait from evap.evaluation.models import ( @@ -396,6 +400,26 @@ def wait_until_page_reloads(self): yield self.wait.until(staleness_of(html_element)) + def search_and_select_in_tom_select(self, field_name: str, *keys: str, clear: bool = False): + self.wait.until(presence_of_element_located((By.CSS_SELECTOR, f"#id_{field_name} ~ .ts-wrapper .ts-control"))) + # Use JS for the click to not accidentally click an item in the tom select + self.selenium.execute_script(f'document.querySelector("#id_{field_name} ~ .ts-wrapper .ts-control").click()') + + if clear: + for el in self.selenium.find_elements( + By.CSS_SELECTOR, f"#id_{field_name} ~ .ts-wrapper .ts-control .remove" + ): + el.click() + + input_field = self.wait.until(presence_of_element_located((By.ID, f"id_{field_name}-ts-control"))) + if clear: + input_field.clear() + input_field.send_keys(*keys) + + self.wait.until(presence_of_element_located((By.CSS_SELECTOR, f"#id_{field_name}-ts-dropdown .option"))) + + input_field.send_keys(Keys.ENTER) + @classmethod def setUpClass(cls) -> None: super().setUpClass() @@ -421,3 +445,32 @@ def classes_of_element(element: WebElement) -> list[str]: def get_open_modals(driver: WebDriver, by: str, value: str) -> list[WebElement]: modals = driver.find_elements(by, value) return [modal for modal in modals if len(modal.shadow_root.find_elements(By.CSS_SELECTOR, "dialog:open")) == 1] + + +class UserProfileSearchLiveServerTest(LiveServerTest): + def conduct_user_profile_search_test( + self, field_name: str, users_to_find: list[UserProfile], users_not_to_find: list[UserProfile] + ): + fields_to_use_for_search = ["first_name_given", "last_name", "email", "full_name"] + + for user in users_to_find: + for field in fields_to_use_for_search: + self.search_and_select_in_tom_select(field_name, getattr(user, field), clear=True) + + found_options = self.selenium.find_elements(By.CSS_SELECTOR, f"#id_{field_name}-ts-dropdown .option") + self.assertEqual(len(found_options), 1) + + self.assertEqual(found_options[0].get_attribute("data-value"), str(user.pk)) + + input_field = self.selenium.find_element(By.ID, f"id_{field_name}") + self.assertEqual(input_field.get_attribute("value"), str(user.pk)) + + for user in users_not_to_find: + for field in fields_to_use_for_search: + input_field = self.selenium.find_element(By.ID, f"id_{field_name}-ts-control") + input_field.clear() + input_field.send_keys(getattr(user, field)) + + self.wait.until( + presence_of_element_located((By.CSS_SELECTOR, f"#id_{field_name}-ts-dropdown .no-results")) + ) diff --git a/evap/evaluation/views.py b/evap/evaluation/views.py index 7dce34bee0..a93b9911bb 100644 --- a/evap/evaluation/views.py +++ b/evap/evaluation/views.py @@ -3,14 +3,16 @@ from django.conf import settings from django.contrib import auth, messages -from django.core.exceptions import SuspiciousOperation +from django.core.exceptions import BadRequest, SuspiciousOperation from django.core.mail import EmailMessage -from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest +from django.db.models import Q, QuerySet +from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest, JsonResponse from django.shortcuts import redirect, render from django.urls import reverse from django.utils.encoding import iri_to_uri from django.utils.http import url_has_allowed_host_and_scheme from django.utils.translation import gettext as _ +from django.views import View from django.views.decorators.debug import sensitive_post_parameters from django.views.decorators.http import require_POST from django.views.i18n import set_language @@ -247,3 +249,25 @@ def set_startpage(request: HttpRequest) -> HttpResponse: user.save() return redirect("evaluation:index") + + +class UserProfileOptionsBaseView(View): + @classmethod + def get_queryset(cls, request: HttpRequest, *args, **kwargs) -> QuerySet[UserProfile]: + return UserProfile.objects.all() + + def get(self, request: HttpRequest, *args, **kwargs) -> JsonResponse: + query = request.GET.get("query") + if query is None: + raise BadRequest("Please provide a search query") + if len(query) < 3: + options = [] + else: + items = self.get_queryset(request).filter( + Q(first_name_given__trigram_similar=query) + | Q(first_name_chosen__trigram_similar=query) + | Q(last_name__trigram_similar=query) + | Q(email__trigram_similar=query) + ) + options = [{"id": item.pk, "text": item.full_name_with_additional_info} for item in items] + return JsonResponse({"options": options}) diff --git a/evap/staff/forms.py b/evap/staff/forms.py index 24b16509a1..23635de011 100644 --- a/evap/staff/forms.py +++ b/evap/staff/forms.py @@ -5,14 +5,18 @@ from django.contrib.auth.models import Group from django.core.exceptions import SuspiciousOperation, ValidationError from django.db import transaction -from django.db.models import Max, Q +from django.db.models import Max, Q, QuerySet from django.forms.models import BaseInlineFormSet from django.forms.widgets import CheckboxSelectMultiple from django.http.request import QueryDict +from django.urls import reverse from django.utils.text import normalize_newlines from django.utils.translation import gettext_lazy as _ -from evap.evaluation.forms import UserModelChoiceField, UserModelMultipleChoiceField +from evap.evaluation.forms import ( + UserModelChoiceField, + UserModelMultipleChoiceField, +) from evap.evaluation.models import ( Contribution, Course, @@ -438,6 +442,13 @@ class Meta: "participants": UserModelMultipleChoiceField, } + @classmethod + def get_participants_queryset(cls, evaluation: Evaluation | None) -> QuerySet[UserProfile]: + queryset = UserProfile.objects.exclude(is_active=False) + if evaluation is not None and evaluation.pk is not None: + queryset = (queryset | evaluation.participants.all()).distinct() + return queryset + def __init__(self, *args, requires_decided_main_language=False, **kwargs): semester = kwargs.pop("semester", None) self.requires_decided_main_language = requires_decided_main_language @@ -456,12 +467,12 @@ def __init__(self, *args, requires_decided_main_language=False, **kwargs): Questionnaire.objects.dropout_questionnaires().filter(visible_questionnaires).distinct() ) - queryset = UserProfile.objects.exclude(is_active=False) - if self.instance.pk is not None: - queryset = (queryset | self.instance.participants.all()).distinct() - self.fields["participants"].queryset = queryset - # this avoids participant lists being cached, e.g. after removing a participant and reloading the page without saving, the participant should appear again - self.fields["participants"].widget.attrs["autocomplete"] = "off" + self.fields["participants"].queryset = self.get_participants_queryset(self.instance) + self.fields["participants"].widget.options_endpoint = ( + reverse("staff:participant_options", args=[self.instance.pk]) + if self.instance.pk + else reverse("staff:participant_options") + ) if general_contribution := self.instance.general_contribution: self.fields["general_questionnaires"].initial = [ @@ -591,7 +602,6 @@ def clean(self): class ContributionForm(forms.ModelForm): - contributor = UserModelChoiceField(queryset=UserProfile.objects.exclude(is_active=False)) evaluation = forms.ModelChoiceField( Evaluation.objects.all(), disabled=True, required=False, widget=forms.HiddenInput() ) @@ -606,6 +616,7 @@ class ContributionForm(forms.ModelForm): class Meta: model = Contribution fields = ("evaluation", "contributor", "questionnaires", "role", "textanswer_visibility", "label", "order") + field_classes = {"contributor": UserModelChoiceField} widgets = { "order": forms.HiddenInput(), # RadioSelects are necessary so each value gets a id_for_label, see #1769. @@ -613,6 +624,10 @@ class Meta: "textanswer_visibility": forms.RadioSelect(), } + @classmethod + def get_contributor_queryset(cls): + return UserProfile.objects.exclude(is_active=False) + def __init__(self, *args, evaluation=None, **kwargs): self.evaluation = evaluation # work around https://code.djangoproject.com/ticket/25880 @@ -623,7 +638,10 @@ def __init__(self, *args, evaluation=None, **kwargs): super().__init__(*args, **kwargs) if self.instance.contributor: - self.fields["contributor"].queryset |= UserProfile.objects.filter(pk=self.instance.contributor.pk) + self.fields["contributor"].queryset = self.get_contributor_queryset() | UserProfile.objects.filter( + pk=self.instance.contributor.pk + ) + self.fields["contributor"].widget.options_endpoint = reverse("staff:fetch_contributor_user_profiles") self.fields["questionnaires"].queryset = ( Questionnaire.objects.contributor_questionnaires() @@ -1032,9 +1050,14 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.user_with_same_email = None + self.remove_messages = [] + evaluations_in_active_semester = Evaluation.objects.filter(course__semester=Semester.active_semester()) self.fields["evaluations_participating_in"].queryset = evaluations_in_active_semester - self.remove_messages = [] + + self.fields["cc_users"].widget.options_endpoint = reverse("staff:user_profile_options") + self.fields["delegates"].widget.options_endpoint = reverse("staff:user_profile_options") + if self.instance.pk: self.fields["evaluations_participating_in"].initial = evaluations_in_active_semester.filter( participants=self.instance @@ -1127,10 +1150,19 @@ class UserMergeSelectionForm(forms.Form): main_user = UserModelChoiceField(UserProfile.objects.all()) other_user = UserModelChoiceField(UserProfile.objects.all()) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["main_user"].widget.options_endpoint = reverse("staff:user_profile_options") + self.fields["other_user"].widget.options_endpoint = reverse("staff:user_profile_options") + class UserEditSelectionForm(forms.Form): user = UserModelChoiceField(UserProfile.objects.all()) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["user"].widget.options_endpoint = reverse("staff:user_profile_options") + class FaqSectionForm(forms.ModelForm): class Meta: diff --git a/evap/staff/tests/test_live.py b/evap/staff/tests/test_live.py index f0d49e32ad..578e3a7d58 100644 --- a/evap/staff/tests/test_live.py +++ b/evap/staff/tests/test_live.py @@ -21,7 +21,7 @@ TextAnswer, UserProfile, ) -from evap.evaluation.tests.tools import LiveServerTest, classes_of_element +from evap.evaluation.tests.tools import LiveServerTest, UserProfileSearchLiveServerTest, classes_of_element class EvaluationEditLiveTest(LiveServerTest): @@ -59,15 +59,7 @@ def test_submit_changes_form_data(self): with self.enter_staff_mode(): self.selenium.get(self.reverse("staff:evaluation_edit", args=[evaluation.pk])) - row = self.wait.until(visibility_of_element_located((By.CSS_SELECTOR, "#id_contributions-0-contributor"))) - tomselect_options = row.get_property("tomselect")["options"] - manager_text = "manager (manager@institution.example.com)" - manager_options = [key for key, value in tomselect_options.items() if value["text"] == manager_text] - self.assertEqual(len(manager_options), 1) - self.selenium.execute_script( - f"""let tomselect = document.querySelector("#id_contributions-0-contributor").tomselect; - tomselect.setValue("{manager_options[0]}");""" - ) + self.search_and_select_in_tom_select("contributions-0-contributor", self.manager.email) submit_btn = self.wait.until( element_to_be_clickable((By.XPATH, "//button[@name='operation' and @value='save']")) @@ -143,12 +135,9 @@ def test_collapse_with_editor_approved(self) -> None: counter = card_header.find_element(By.CSS_SELECTOR, ".rounded-pill") self.assertEqual(counter.text, "20") - tomselect_input = self.wait.until( - visibility_of_element_located((By.CSS_SELECTOR, "input#id_participants-ts-control")) - ) - tomselect_input.click() - tomselect_input.send_keys("participant") - self.selenium.find_element(By.CSS_SELECTOR, ".option.active").click() + self.wait.until(visibility_of_element_located((By.CSS_SELECTOR, "#id_participants ~ .ts-wrapper .ts-control"))) + + self.search_and_select_in_tom_select("participants", "participant") self.assertEqual(counter.text, "21") random_participant_remove_button = self.selenium.find_element( @@ -334,3 +323,99 @@ def test_edit_textanswer_redirect(self): self.wait.until( invisibility_of_element_located((By.XPATH, "//div[contains(text(), 'this is a dummy answer')]")) ) + + +class StaffUserProfileSearchLiveTest(UserProfileSearchLiveServerTest): + def test_evaluation_form_set_participants(self) -> None: + """Test participants field in EvaluationForm.""" + possible_participant = baker.make( + UserProfile, first_name_given="Jane", last_name="Doe", email="jane.doe@institution.example.com" + ) + not_active_user = baker.make( + UserProfile, + first_name_given="User 1", + last_name="User 1", + email="user1@institution.example.com", + is_active=False, + ) + + evaluation = baker.make( + Evaluation, + course=baker.make(Course, programs=[baker.make(Program)], responsibles=[baker.make(UserProfile)]), + state=Evaluation.State.PREPARED, + vote_start_datetime=datetime(2099, 1, 1, 0, 0), + vote_end_date=date(2099, 12, 31), + ) + + with self.enter_staff_mode(): + self.selenium.get(self.reverse("staff:evaluation_edit", args=[evaluation.pk])) + + self.conduct_user_profile_search_test("participants", [possible_participant], [not_active_user]) + + def test_evaluation_form_set_contributor(self) -> None: + """Test contributor field in ContributionForm in EvaluationForm formset.""" + possible_contributor = baker.make( + UserProfile, first_name_given="Jane", last_name="Doe", email="jane.doe@institution.example.com" + ) + not_active_user = baker.make( + UserProfile, + first_name_given="User 1", + last_name="User 1", + email="user1@institution.example.com", + is_active=False, + ) + + responsible = baker.make(UserProfile) + evaluation = baker.make( + Evaluation, + course=baker.make(Course, programs=[baker.make(Program)], responsibles=[responsible]), + state=Evaluation.State.PREPARED, + vote_start_datetime=datetime(2099, 1, 1, 0, 0), + vote_end_date=date(2099, 12, 31), + ) + baker.make(Contribution, evaluation=evaluation, contributor=responsible) + + with self.enter_staff_mode(): + self.selenium.get(self.reverse("staff:evaluation_edit", args=[evaluation.pk])) + + self.conduct_user_profile_search_test( + "contributions-0-contributor", [possible_contributor], [not_active_user] + ) + + def test_user_edit_selection(self) -> None: + """Test user field in UserEditSelectionForm.""" + possible_user = baker.make( + UserProfile, first_name_given="Jane", last_name="Doe", email="jane.doe@institution.example.com" + ) + + with self.enter_staff_mode(): + self.selenium.get(self.reverse("staff:user_index")) + + self.conduct_user_profile_search_test("user", [possible_user], []) + + def test_user_edit_set_cc_users_and_delegates(self) -> None: + """Test cc_users and delegates fields in UserForm.""" + user_to_edit = baker.make( + UserProfile, first_name_given="John", last_name="Doe", email="john.doe@institution.example.com" + ) + other_user = baker.make( + UserProfile, first_name_given="Jane", last_name="Doe", email="jane.doe@institution.example.com" + ) + + with self.enter_staff_mode(): + self.selenium.get(self.reverse("staff:user_edit", args=[user_to_edit.pk])) + + self.conduct_user_profile_search_test("cc_users", [other_user], []) + self.conduct_user_profile_search_test("delegates", [other_user], []) + + def test_user_merge_set_main_and_other_user(self) -> None: + """Test main_user and other_user fields in UserMergeSelectionForm.""" + possible_user = baker.make( + UserProfile, first_name_given="John", last_name="Doe", email="john.doe@institution.example.com" + ) + + with self.enter_staff_mode(): + self.selenium.get(self.reverse("staff:user_merge_selection")) + + self.conduct_user_profile_search_test("main_user", [possible_user], []) + self.conduct_user_profile_search_test("other_user", [possible_user], []) diff --git a/evap/staff/tests/test_views.py b/evap/staff/tests/test_views.py index 6f73cf56c7..0bd99e27a8 100644 --- a/evap/staff/tests/test_views.py +++ b/evap/staff/tests/test_views.py @@ -207,7 +207,7 @@ def setUpTestData(cls): def test_redirect(self): page = self.app.get(self.url, user=self.manager, status=200) form = page.forms["user-edit-form"] - form["user"] = self.some_user.pk + form["user"].force_value(self.some_user.pk) response = form.submit(status=302) self.assertEqual(response.location, f"/staff/user/{self.some_user.pk}/edit") @@ -360,8 +360,8 @@ 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 + form["main_user"].force_value(self.main_user.pk) + form["other_user"].force_value(self.other_user.pk) page = form.submit().follow() @@ -1863,7 +1863,7 @@ def prepare_form(self, name_en, name_de): form["type"] = self.course_type.pk form["programs"] = [self.program.pk] form["is_private"] = False - form["responsibles"] = [self.responsible.pk] + form["responsibles"].force_value([self.responsible.pk]) form["name_en"] = name_en form["name_de"] = name_de return form @@ -1932,7 +1932,7 @@ def _set_valid_form(self, form): form["contributions-INITIAL_FORMS"] = 0 form["contributions-MAX_NUM_FORMS"] = 5 form["contributions-0-evaluation"] = "" - form["contributions-0-contributor"] = self.manager.pk + form["contributions-0-contributor"].force_value(self.manager.pk) form["contributions-0-questionnaires"] = [self.q2.pk] form["contributions-0-order"] = 0 form["contributions-0-role"] = Contribution.Role.EDITOR diff --git a/evap/staff/urls.py b/evap/staff/urls.py index 5efc41f4ea..0e7f61d037 100644 --- a/evap/staff/urls.py +++ b/evap/staff/urls.py @@ -101,4 +101,13 @@ path("enter_staff_mode", views.enter_staff_mode, name="enter_staff_mode"), path("exit_staff_mode", views.exit_staff_mode, name="exit_staff_mode"), + + path("user_profiles/", views.AllUserProfileOptionsView.as_view(), + name="user_profile_options"), + path("user_profiles/participants/", views.ParticipantsUserProfileOptionsView.as_view(), + name="participant_options"), + path("user_profiles/participants/", views.ParticipantsUserProfileOptionsView.as_view(), + name="participant_options"), + path("user_profiles/contributor/", views.ContributorUserProfileOptionsView.as_view(), + name="fetch_contributor_user_profiles"), ] diff --git a/evap/staff/views.py b/evap/staff/views.py index 83ff6de5cf..0bbc79f2a3 100644 --- a/evap/staff/views.py +++ b/evap/staff/views.py @@ -24,6 +24,7 @@ OuterRef, Prefetch, Q, + QuerySet, Sum, When, ) @@ -74,6 +75,7 @@ sort_formset, temporary_receiver, ) +from evap.evaluation.views import UserProfileOptionsBaseView from evap.grades.models import GradeDocument from evap.results.exporters import ResultsExporter from evap.results.tools import TextResult, calculate_average_distribution, distribution_to_grade @@ -2729,3 +2731,27 @@ def enter_staff_mode(request): def exit_staff_mode(request): staff_mode.exit_staff_mode(request) return redirect("evaluation:index") + + +@staff_permission_required +class ParticipantsUserProfileOptionsView(UserProfileOptionsBaseView): + @classmethod + def get_queryset(cls, request, *args, **kwargs) -> QuerySet[UserProfile]: + evaluation = get_object_or_404(Evaluation, id=kwargs["evaluation_id"]) if kwargs.get("evaluation_id") else None + return ( + super() + .get_queryset(request, *args, **kwargs) + .filter(pk__in=EvaluationForm.get_participants_queryset(evaluation)) + ) + + +@staff_permission_required +class ContributorUserProfileOptionsView(UserProfileOptionsBaseView): + @classmethod + def get_queryset(cls, request, *args, **kwargs) -> QuerySet[UserProfile]: + return super().get_queryset(request, *args, **kwargs).filter(pk__in=ContributionForm.get_contributor_queryset()) + + +@staff_permission_required +class AllUserProfileOptionsView(UserProfileOptionsBaseView): + pass