From f8ad165d8d4b6381747c78c07a2a98169b909258 Mon Sep 17 00:00:00 2001 From: Jonathan Weth Date: Mon, 16 Mar 2026 20:28:17 +0100 Subject: [PATCH 01/11] Migrate some user fields to server side search --- evap/evaluation/forms.py | 20 ++++++++ evap/evaluation/templates/base.html | 25 +++++++-- .../django/forms/widgets/server_select.html | 11 ++++ evap/evaluation/views.py | 35 ++++++++++++- evap/staff/forms.py | 51 +++++++++++++++---- evap/staff/urls.py | 9 ++++ evap/staff/views.py | 26 ++++++++++ 7 files changed, 160 insertions(+), 17 deletions(-) create mode 100644 evap/evaluation/templates/django/forms/widgets/server_select.html diff --git a/evap/evaluation/forms.py b/evap/evaluation/forms.py index adeb8638a1..cd113b623f 100644 --- a/evap/evaluation/forms.py +++ b/evap/evaluation/forms.py @@ -157,3 +157,23 @@ def __init__(self, *args, **kwargs) -> None: class Meta: model = UserProfile fields = ("notes",) + + +class ServerSearchSelect(forms.Select): + template_name = "django/forms/widgets/server_select.html" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.attrs["autocomplete"] = "off" + + @property + def search_url(self): + return self.attrs["data-tomselect-server-search"] + + @search_url.setter + def search_url(self, value): + self.attrs["data-tomselect-server-search"] = value + + +class ServerSearchSelectMultiple(ServerSearchSelect, forms.SelectMultiple): + pass diff --git a/evap/evaluation/templates/base.html b/evap/evaluation/templates/base.html index c1e3309182..6c767ab65e 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-search")) ? 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,25 @@ } else { return '
{% translate "No results found" %}
'; } - } + }, + not_loading: (data, escape) => `
{% translate "Please enter ${ minimumInputLength } characters or more..." %}
` }, + ...(element.hasAttribute("data-tomselect-server-search") ? { + shouldLoad: (query) => query.length >= minimumInputLength, + load: (query, callback) => { + var url = element.getAttribute("data-tomselect-server-search") + "?query=" + encodeURIComponent(query); + fetch(url) + .then(response => response.json()) + .then(json => { + callback(json.items); + }).catch(() => { + callback(); + }); + }, + valueField: 'id', + labelField: 'label', + searchField: 'label' + } : {}), closeAfterSelect: true, // also clears search input on enter. plugins: {}, hideSelected: false, @@ -217,7 +234,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_select.html b/evap/evaluation/templates/django/forms/widgets/server_select.html new file mode 100644 index 0000000000..e0d487e635 --- /dev/null +++ b/evap/evaluation/templates/django/forms/widgets/server_select.html @@ -0,0 +1,11 @@ + diff --git a/evap/evaluation/views.py b/evap/evaluation/views.py index 7dce34bee0..e1023d452a 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,32 @@ def set_startpage(request: HttpRequest) -> HttpResponse: user.save() return redirect("evaluation:index") + + +class TomSelectSearchView(View): + def get(self, request, *args, **kwargs): + query = request.GET.get("query") + if query is None: + raise BadRequest("Please provide a search query") + items = self.search(query, request, *args, **kwargs) + items = [{"id": item.id, "label": self.get_item_label(item)} for item in items] + return JsonResponse({"items": items}) + + def get_item_label(self, item) -> str: + return str(item) + + def search(self, query, request, *args, **kwargs) -> QuerySet: + raise NotImplementedError + + +class UserProfileSearchView(TomSelectSearchView): + def get_item_label(self, item) -> str: + return item.full_name_with_additional_info + + def search(self, query, request, *args, **kwargs) -> QuerySet[UserProfile]: + return UserProfile.objects.filter( + Q(first_name_given__icontains=query) + | Q(first_name_chosen__icontains=query) + | Q(last_name__icontains=query) + | Q(email__icontains=query) + ) diff --git a/evap/staff/forms.py b/evap/staff/forms.py index 24b16509a1..4a145a0a66 100644 --- a/evap/staff/forms.py +++ b/evap/staff/forms.py @@ -5,14 +5,20 @@ 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 ( + ServerSearchSelect, + ServerSearchSelectMultiple, + UserModelChoiceField, + UserModelMultipleChoiceField, +) from evap.evaluation.models import ( Contribution, Course, @@ -437,6 +443,14 @@ class Meta: field_classes = { "participants": UserModelMultipleChoiceField, } + widgets = {"participants": ServerSearchSelectMultiple()} + + @classmethod + def get_participants_queryset(cls, evaluation: Evaluation | None) -> QuerySet[UserProfile]: + queryset = UserProfile.objects.exclude(is_active=False) + if 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) @@ -456,12 +470,10 @@ 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.search_url = reverse( + "staff:fetch_participants_user_profiles", args=[self.instance.pk] + ) if general_contribution := self.instance.general_contribution: self.fields["general_questionnaires"].initial = [ @@ -591,7 +603,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,13 +617,19 @@ 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. "role": forms.RadioSelect(), "textanswer_visibility": forms.RadioSelect(), + "contributor": ServerSearchSelect(), } + @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 +640,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.search_url = reverse("staff:fetch_contributor_user_profiles") self.fields["questionnaires"].queryset = ( Questionnaire.objects.contributor_questionnaires() @@ -1028,13 +1048,22 @@ class Meta: "delegates": UserModelMultipleChoiceField, "cc_users": UserModelMultipleChoiceField, } + widgets = { + "delegates": ServerSearchSelectMultiple, + "cc_users": ServerSearchSelectMultiple, + } 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.search_url = reverse("staff:fetch_user_profiles") + self.fields["delegates"].widget.search_url = reverse("staff:fetch_user_profiles") + if self.instance.pk: self.fields["evaluations_participating_in"].initial = evaluations_in_active_semester.filter( participants=self.instance diff --git a/evap/staff/urls.py b/evap/staff/urls.py index 5efc41f4ea..aa630195e2 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.AllUserProfileSearchView.as_view(), + name="fetch_user_profiles"), + path("user_profiles/participants/", views.ParticipantsUserProfileSearchView.as_view(), + name="fetch_participants_user_profiles"), + path("user_profiles/participants/", views.ParticipantsUserProfileSearchView.as_view(), + name="fetch_participants_user_profiles"), + path("user_profiles/contributor/", views.ContributorUserProfileSearchView.as_view(), + name="fetch_contributor_user_profiles"), ] diff --git a/evap/staff/views.py b/evap/staff/views.py index 83ff6de5cf..d395598eb3 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 UserProfileSearchView 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 ParticipantsUserProfileSearchView(UserProfileSearchView): + def search(self, query, request, *args, **kwargs) -> QuerySet[UserProfile]: + evaluation = get_object_or_404(Evaluation, id=kwargs["evaluation"]) if kwargs.get("evaluation") else None + return ( + super() + .search(query, request, *args, **kwargs) + .filter(pk__in=EvaluationForm.get_participants_queryset(evaluation)) + ) + + +@staff_permission_required +class ContributorUserProfileSearchView(UserProfileSearchView): + def search(self, query, request, *args, **kwargs) -> QuerySet[UserProfile]: + return ( + super().search(query, request, *args, **kwargs).filter(pk__in=ContributionForm.get_contributor_queryset()) + ) + + +@staff_permission_required +class AllUserProfileSearchView(UserProfileSearchView): + pass From 2a3c3d11d6bea2e6d8d5c2e06b90663a4e51107f Mon Sep 17 00:00:00 2001 From: Jonathan Weth Date: Mon, 16 Mar 2026 21:10:23 +0100 Subject: [PATCH 02/11] Use more server side search --- evap/contributor/forms.py | 13 +++++++++++-- evap/contributor/urls.py | 4 +++- evap/contributor/views.py | 13 ++++++++++++- evap/staff/forms.py | 15 ++++++++++++--- 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/evap/contributor/forms.py b/evap/contributor/forms.py index c6640371b4..20f27c4a3a 100644 --- a/evap/contributor/forms.py +++ b/evap/contributor/forms.py @@ -3,9 +3,10 @@ from django import forms from django.db.models import Q 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 ServerSearchSelect, 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 @@ -157,6 +158,14 @@ def __init__(self, *args, **kwargs): class DelegateSelectionForm(forms.Form): + @staticmethod + def get_delegate_to_queryset(): + return UserProfile.objects.exclude(is_active=False).exclude(is_proxy_user=True) + delegate_to = UserModelChoiceField( - label=_("Delegate to"), queryset=UserProfile.objects.exclude(is_active=False).exclude(is_proxy_user=True) + label=_("Delegate to"), queryset=get_delegate_to_queryset(), widget=ServerSearchSelect() ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["delegate_to"].widget.search_url = reverse("contributor:fetch_delegate_to_user_profiles") diff --git a/evap/contributor/urls.py b/evap/contributor/urls.py index e0aa6875c1..95ee1a1525 100644 --- a/evap/contributor/urls.py +++ b/evap/contributor/urls.py @@ -10,5 +10,7 @@ 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/delegate_to/", views.DelegateToUserProfileSearchView.as_view(), + name="fetch_delegate_to_user_profiles"), ] diff --git a/evap/contributor/views.py b/evap/contributor/views.py index aa62b74576..68f1c56833 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 UserProfileSearchView 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,13 @@ 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 DelegateToUserProfileSearchView(UserProfileSearchView): + def search(self, query, request, *args, **kwargs) -> QuerySet[UserProfile]: + return ( + super() + .search(query, request, *args, **kwargs) + .filter(pk__in=DelegateSelectionForm.get_delegate_to_queryset()) + ) diff --git a/evap/staff/forms.py b/evap/staff/forms.py index 4a145a0a66..4948bf4f56 100644 --- a/evap/staff/forms.py +++ b/evap/staff/forms.py @@ -1153,12 +1153,21 @@ def save(self, *args, **kw): class UserMergeSelectionForm(forms.Form): - main_user = UserModelChoiceField(UserProfile.objects.all()) - other_user = UserModelChoiceField(UserProfile.objects.all()) + main_user = UserModelChoiceField(UserProfile.objects.all(), widget=ServerSearchSelect()) + other_user = UserModelChoiceField(UserProfile.objects.all(), widget=ServerSearchSelect()) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["main_user"].widget.search_url = reverse("staff:fetch_user_profiles") + self.fields["other_user"].widget.search_url = reverse("staff:fetch_user_profiles") class UserEditSelectionForm(forms.Form): - user = UserModelChoiceField(UserProfile.objects.all()) + user = UserModelChoiceField(UserProfile.objects.all(), widget=ServerSearchSelect()) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["user"].widget.search_url = reverse("staff:fetch_user_profiles") class FaqSectionForm(forms.ModelForm): From 28e1cbd4fac908a3ad4d0d8e2edf2da5996e0ef9 Mon Sep 17 00:00:00 2001 From: Jonathan Weth Date: Mon, 16 Mar 2026 22:00:29 +0100 Subject: [PATCH 03/11] Use server side search for more user fields --- evap/contributor/forms.py | 29 ++++++++++++++++------- evap/contributor/urls.py | 6 +++-- evap/contributor/views.py | 15 ++++++++++-- evap/evaluation/forms.py | 49 ++++++++++++++++++++------------------- 4 files changed, 62 insertions(+), 37 deletions(-) diff --git a/evap/contributor/forms.py b/evap/contributor/forms.py index 20f27c4a3a..04a8e8d7d0 100644 --- a/evap/contributor/forms.py +++ b/evap/contributor/forms.py @@ -1,12 +1,17 @@ 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 ServerSearchSelect, UserModelChoiceField, UserModelMultipleChoiceField +from evap.evaluation.forms import ( + ServerSearchSelect, + ServerSearchSelectMultiple, + 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 @@ -43,6 +48,7 @@ class Meta: field_classes = { "participants": UserModelMultipleChoiceField, } + widgets = {"participants": ServerSearchSelectMultiple()} def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -70,10 +76,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.search_url = reverse("contributor:fetch_participants_user_profiles") if general_contribution := self.instance.general_contribution: self.fields["general_questionnaires"].initial = [ @@ -91,6 +95,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.pk is not None: + queryset = (queryset | evaluation.participants.all()).distinct() + return queryset + def clean(self): super().clean() @@ -159,13 +170,13 @@ def __init__(self, *args, **kwargs): class DelegateSelectionForm(forms.Form): @staticmethod - def get_delegate_to_queryset(): + def get_delegates_queryset(): return UserProfile.objects.exclude(is_active=False).exclude(is_proxy_user=True) delegate_to = UserModelChoiceField( - label=_("Delegate to"), queryset=get_delegate_to_queryset(), widget=ServerSearchSelect() + label=_("Delegate to"), queryset=get_delegates_queryset(), widget=ServerSearchSelect() ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["delegate_to"].widget.search_url = reverse("contributor:fetch_delegate_to_user_profiles") + self.fields["delegate_to"].widget.search_url = reverse("contributor:fetch_delegates_user_profiles") diff --git a/evap/contributor/urls.py b/evap/contributor/urls.py index 95ee1a1525..9c6d2d952a 100644 --- a/evap/contributor/urls.py +++ b/evap/contributor/urls.py @@ -11,6 +11,8 @@ 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("user_profiles/delegate_to/", views.DelegateToUserProfileSearchView.as_view(), - name="fetch_delegate_to_user_profiles"), + path("user_profiles/delegates/", views.DelegatesUserProfileSearchView.as_view(), + name="fetch_delegates_user_profiles"), + path("user_profiles/participants/", views.ParticipantsUserProfileSearchView.as_view(), + name="fetch_participants_user_profiles"), ] diff --git a/evap/contributor/views.py b/evap/contributor/views.py index 68f1c56833..56711e2ea0 100644 --- a/evap/contributor/views.py +++ b/evap/contributor/views.py @@ -308,10 +308,21 @@ def export(request): @responsible_or_contributor_or_delegate_required -class DelegateToUserProfileSearchView(UserProfileSearchView): +class DelegatesUserProfileSearchView(UserProfileSearchView): def search(self, query, request, *args, **kwargs) -> QuerySet[UserProfile]: return ( super() .search(query, request, *args, **kwargs) - .filter(pk__in=DelegateSelectionForm.get_delegate_to_queryset()) + .filter(pk__in=DelegateSelectionForm.get_delegates_queryset()) + ) + + +@responsible_or_contributor_or_delegate_required +class ParticipantsUserProfileSearchView(UserProfileSearchView): + def search(self, query, request, *args, **kwargs) -> QuerySet[UserProfile]: + evaluation = get_object_or_404(Evaluation, id=kwargs["evaluation"]) if kwargs.get("evaluation") else None + return ( + super() + .search(query, request, *args, **kwargs) + .filter(pk__in=EvaluationForm.get_participants_queryset(evaluation)) ) diff --git a/evap/evaluation/forms.py b/evap/evaluation/forms.py index cd113b623f..2ec04bd813 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,26 @@ logger = logging.getLogger(__name__) +class ServerSearchSelect(forms.Select): + template_name = "django/forms/widgets/server_select.html" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.attrs["autocomplete"] = "off" + + @property + def search_url(self): + return self.attrs["data-tomselect-server-search"] + + @search_url.setter + def search_url(self, value): + self.attrs["data-tomselect-server-search"] = value + + +class ServerSearchSelectMultiple(ServerSearchSelect, forms.SelectMultiple): + pass + + class LoginEmailForm(forms.Form): """Form encapsulating the login with email and password, for example from an Active Directory.""" @@ -109,20 +130,20 @@ 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.exclude(is_active=False).exclude(is_proxy_user=True), + required=False, + widget=ServerSearchSelectMultiple(), ) 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.search_url = reverse("contributor:fetch_delegates_user_profiles") def save(self, *args, **kwargs) -> None: super().save(*args, **kwargs) @@ -157,23 +178,3 @@ def __init__(self, *args, **kwargs) -> None: class Meta: model = UserProfile fields = ("notes",) - - -class ServerSearchSelect(forms.Select): - template_name = "django/forms/widgets/server_select.html" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.attrs["autocomplete"] = "off" - - @property - def search_url(self): - return self.attrs["data-tomselect-server-search"] - - @search_url.setter - def search_url(self, value): - self.attrs["data-tomselect-server-search"] = value - - -class ServerSearchSelectMultiple(ServerSearchSelect, forms.SelectMultiple): - pass From 0d2cb40234c56dffe2c6e1425d6a9ffbe2c5a11d Mon Sep 17 00:00:00 2001 From: Jonathan Weth Date: Mon, 20 Apr 2026 21:34:00 +0200 Subject: [PATCH 04/11] Clean up naming and structure of user profile server side search --- evap/contributor/forms.py | 17 +++------- evap/contributor/urls.py | 8 ++--- evap/contributor/views.py | 12 +++---- evap/evaluation/forms.py | 28 ++++++++-------- evap/evaluation/models.py | 3 ++ evap/evaluation/templates/base.html | 12 +++---- ...t.html => server_side_options_select.html} | 3 +- evap/evaluation/views.py | 25 ++++++++------- evap/staff/forms.py | 32 +++++++------------ evap/staff/urls.py | 8 ++--- evap/staff/views.py | 8 ++--- 11 files changed, 72 insertions(+), 84 deletions(-) rename evap/evaluation/templates/django/forms/widgets/{server_select.html => server_side_options_select.html} (75%) diff --git a/evap/contributor/forms.py b/evap/contributor/forms.py index 04a8e8d7d0..40f628972e 100644 --- a/evap/contributor/forms.py +++ b/evap/contributor/forms.py @@ -7,8 +7,6 @@ from django.utils.translation import gettext_lazy as _ from evap.evaluation.forms import ( - ServerSearchSelect, - ServerSearchSelectMultiple, UserModelChoiceField, UserModelMultipleChoiceField, ) @@ -48,7 +46,6 @@ class Meta: field_classes = { "participants": UserModelMultipleChoiceField, } - widgets = {"participants": ServerSearchSelectMultiple()} def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -77,7 +74,7 @@ def __init__(self, *args, **kwargs): self.fields["vote_end_date"].localize = True self.fields["participants"].queryset = self.get_participants_queryset(self.instance) - self.fields["participants"].widget.search_url = reverse("contributor:fetch_participants_user_profiles") + self.fields["participants"].widget.options_endpoint = reverse("contributor:participant_options") if general_contribution := self.instance.general_contribution: self.fields["general_questionnaires"].initial = [ @@ -98,7 +95,7 @@ def __init__(self, *args, **kwargs): @classmethod def get_participants_queryset(cls, evaluation: Evaluation | None) -> QuerySet[UserProfile]: queryset = UserProfile.objects.exclude(is_active=False) - if evaluation.pk is not None: + if evaluation is not None and evaluation.pk is not None: queryset = (queryset | evaluation.participants.all()).distinct() return queryset @@ -169,14 +166,8 @@ def __init__(self, *args, **kwargs): class DelegateSelectionForm(forms.Form): - @staticmethod - def get_delegates_queryset(): - return UserProfile.objects.exclude(is_active=False).exclude(is_proxy_user=True) - - delegate_to = UserModelChoiceField( - label=_("Delegate to"), queryset=get_delegates_queryset(), widget=ServerSearchSelect() - ) + delegate_to = UserModelChoiceField(label=_("Delegate to"), queryset=UserProfile.objects.get_delegates()) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["delegate_to"].widget.search_url = reverse("contributor:fetch_delegates_user_profiles") + self.fields["delegate_to"].widget.options_endpoint = reverse("contributor:delegate_options") diff --git a/evap/contributor/urls.py b/evap/contributor/urls.py index 9c6d2d952a..94e2d93532 100644 --- a/evap/contributor/urls.py +++ b/evap/contributor/urls.py @@ -11,8 +11,8 @@ 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("user_profiles/delegates/", views.DelegatesUserProfileSearchView.as_view(), - name="fetch_delegates_user_profiles"), - path("user_profiles/participants/", views.ParticipantsUserProfileSearchView.as_view(), - name="fetch_participants_user_profiles"), + 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 56711e2ea0..ad34cdc050 100644 --- a/evap/contributor/views.py +++ b/evap/contributor/views.py @@ -26,7 +26,7 @@ get_object_from_dict_pk_entry_or_logged_40x, sort_formset, ) -from evap.evaluation.views import UserProfileSearchView +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 @@ -308,17 +308,13 @@ def export(request): @responsible_or_contributor_or_delegate_required -class DelegatesUserProfileSearchView(UserProfileSearchView): +class DelegateOptionsView(UserProfileOptionsBaseView): def search(self, query, request, *args, **kwargs) -> QuerySet[UserProfile]: - return ( - super() - .search(query, request, *args, **kwargs) - .filter(pk__in=DelegateSelectionForm.get_delegates_queryset()) - ) + return super().search(query, request, *args, **kwargs).filter(pk__in=UserProfile.objects.get_delegates()) @responsible_or_contributor_or_delegate_required -class ParticipantsUserProfileSearchView(UserProfileSearchView): +class ParticipantOptionsView(UserProfileOptionsBaseView): def search(self, query, request, *args, **kwargs) -> QuerySet[UserProfile]: evaluation = get_object_or_404(Evaluation, id=kwargs["evaluation"]) if kwargs.get("evaluation") else None return ( diff --git a/evap/evaluation/forms.py b/evap/evaluation/forms.py index 2ec04bd813..0361d4150c 100644 --- a/evap/evaluation/forms.py +++ b/evap/evaluation/forms.py @@ -15,23 +15,24 @@ logger = logging.getLogger(__name__) -class ServerSearchSelect(forms.Select): - template_name = "django/forms/widgets/server_select.html" +class ServerSideOptionsSelectWidget(forms.Select): + template_name = "django/forms/widgets/server_side_options_select.html" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.attrs["autocomplete"] = "off" + if kwargs.get("options_endpoint"): + self.options_endpoint = kwargs["options_endpoint"] @property - def search_url(self): - return self.attrs["data-tomselect-server-search"] + def options_endpoint(self): + return self.attrs["data-tomselect-server-side-options-endpoint"] - @search_url.setter - def search_url(self, value): - self.attrs["data-tomselect-server-search"] = value + @options_endpoint.setter + def options_endpoint(self, value): + self.attrs["data-tomselect-server-side-options-endpoint"] = value -class ServerSearchSelectMultiple(ServerSearchSelect, forms.SelectMultiple): +class ServerSideOptionsSelectMultipleWidget(ServerSideOptionsSelectWidget, forms.SelectMultiple): pass @@ -117,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 @@ -130,9 +133,8 @@ 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), + queryset=UserProfile.objects.get_delegates(), required=False, - widget=ServerSearchSelectMultiple(), ) class Meta: @@ -143,7 +145,7 @@ 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.search_url = reverse("contributor:fetch_delegates_user_profiles") + 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/models.py b/evap/evaluation/models.py index 3be4f8a095..8d975699b7 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_delegates(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 6c767ab65e..9701135060 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 || element.hasAttribute("data-tomselect-server-search")) ? 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 @@ -172,21 +172,21 @@ }, not_loading: (data, escape) => `
{% translate "Please enter ${ minimumInputLength } characters or more..." %}
` }, - ...(element.hasAttribute("data-tomselect-server-search") ? { + ...(element.hasAttribute("data-tomselect-server-side-options-endpoint") ? { shouldLoad: (query) => query.length >= minimumInputLength, load: (query, callback) => { - var url = element.getAttribute("data-tomselect-server-search") + "?query=" + encodeURIComponent(query); + const url = element.getAttribute("data-tomselect-server-side-options-endpoint") + "?query=" + encodeURIComponent(query); fetch(url) .then(response => response.json()) .then(json => { - callback(json.items); + callback(json.options); }).catch(() => { callback(); }); }, valueField: 'id', - labelField: 'label', - searchField: 'label' + labelField: 'text', + searchField: 'text' } : {}), closeAfterSelect: true, // also clears search input on enter. plugins: {}, diff --git a/evap/evaluation/templates/django/forms/widgets/server_select.html b/evap/evaluation/templates/django/forms/widgets/server_side_options_select.html similarity index 75% rename from evap/evaluation/templates/django/forms/widgets/server_select.html rename to evap/evaluation/templates/django/forms/widgets/server_side_options_select.html index e0d487e635..141fc43e85 100644 --- a/evap/evaluation/templates/django/forms/widgets/server_select.html +++ b/evap/evaluation/templates/django/forms/widgets/server_side_options_select.html @@ -1,4 +1,5 @@ - + {# Use this to render currently selected options, new options are fetched from server #} {% for group_name, group_choices, group_index in widget.optgroups %} {% if group_name %} diff --git a/evap/evaluation/views.py b/evap/evaluation/views.py index e1023d452a..d27a5460ac 100644 --- a/evap/evaluation/views.py +++ b/evap/evaluation/views.py @@ -1,11 +1,12 @@ import logging +from abc import abstractmethod from datetime import date, timedelta from django.conf import settings from django.contrib import auth, messages from django.core.exceptions import BadRequest, SuspiciousOperation from django.core.mail import EmailMessage -from django.db.models import Q, QuerySet +from django.db.models import Model, Q, QuerySet from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest, JsonResponse from django.shortcuts import redirect, render from django.urls import reverse @@ -251,27 +252,29 @@ def set_startpage(request: HttpRequest) -> HttpResponse: return redirect("evaluation:index") -class TomSelectSearchView(View): - def get(self, request, *args, **kwargs): +class ServerSideSelectOptionsBaseView[T: Model](View): + def get(self, request: HttpRequest, *args, **kwargs) -> JsonResponse: query = request.GET.get("query") if query is None: raise BadRequest("Please provide a search query") items = self.search(query, request, *args, **kwargs) - items = [{"id": item.id, "label": self.get_item_label(item)} for item in items] - return JsonResponse({"items": items}) + options = [{"id": item.pk, "text": self.get_item_label(item)} for item in items] + return JsonResponse({"options": options}) - def get_item_label(self, item) -> str: - return str(item) + @abstractmethod + def get_item_label(self, item: T) -> str: + pass - def search(self, query, request, *args, **kwargs) -> QuerySet: - raise NotImplementedError + @abstractmethod + def search(self, query: str, request: HttpRequest, *args, **kwargs) -> QuerySet[T]: + pass -class UserProfileSearchView(TomSelectSearchView): +class UserProfileOptionsBaseView(ServerSideSelectOptionsBaseView[UserProfile]): def get_item_label(self, item) -> str: return item.full_name_with_additional_info - def search(self, query, request, *args, **kwargs) -> QuerySet[UserProfile]: + def search(self, query, request, *args, **kwargs): return UserProfile.objects.filter( Q(first_name_given__icontains=query) | Q(first_name_chosen__icontains=query) diff --git a/evap/staff/forms.py b/evap/staff/forms.py index 4948bf4f56..516a3c8370 100644 --- a/evap/staff/forms.py +++ b/evap/staff/forms.py @@ -14,8 +14,6 @@ from django.utils.translation import gettext_lazy as _ from evap.evaluation.forms import ( - ServerSearchSelect, - ServerSearchSelectMultiple, UserModelChoiceField, UserModelMultipleChoiceField, ) @@ -443,12 +441,11 @@ class Meta: field_classes = { "participants": UserModelMultipleChoiceField, } - widgets = {"participants": ServerSearchSelectMultiple()} @classmethod def get_participants_queryset(cls, evaluation: Evaluation | None) -> QuerySet[UserProfile]: queryset = UserProfile.objects.exclude(is_active=False) - if evaluation.pk is not None: + if evaluation is not None and evaluation.pk is not None: queryset = (queryset | evaluation.participants.all()).distinct() return queryset @@ -471,8 +468,8 @@ def __init__(self, *args, requires_decided_main_language=False, **kwargs): ) self.fields["participants"].queryset = self.get_participants_queryset(self.instance) - self.fields["participants"].widget.search_url = reverse( - "staff:fetch_participants_user_profiles", args=[self.instance.pk] + self.fields["participants"].widget.options_endpoint = reverse( + "staff:participant_options", args=[self.instance.pk] ) if general_contribution := self.instance.general_contribution: @@ -623,7 +620,6 @@ class Meta: # RadioSelects are necessary so each value gets a id_for_label, see #1769. "role": forms.RadioSelect(), "textanswer_visibility": forms.RadioSelect(), - "contributor": ServerSearchSelect(), } @classmethod @@ -643,7 +639,7 @@ def __init__(self, *args, evaluation=None, **kwargs): self.fields["contributor"].queryset = self.get_contributor_queryset() | UserProfile.objects.filter( pk=self.instance.contributor.pk ) - self.fields["contributor"].widget.search_url = reverse("staff:fetch_contributor_user_profiles") + self.fields["contributor"].widget.options_endpoint = reverse("staff:fetch_contributor_user_profiles") self.fields["questionnaires"].queryset = ( Questionnaire.objects.contributor_questionnaires() @@ -1048,10 +1044,6 @@ class Meta: "delegates": UserModelMultipleChoiceField, "cc_users": UserModelMultipleChoiceField, } - widgets = { - "delegates": ServerSearchSelectMultiple, - "cc_users": ServerSearchSelectMultiple, - } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1061,8 +1053,8 @@ def __init__(self, *args, **kwargs): evaluations_in_active_semester = Evaluation.objects.filter(course__semester=Semester.active_semester()) self.fields["evaluations_participating_in"].queryset = evaluations_in_active_semester - self.fields["cc_users"].widget.search_url = reverse("staff:fetch_user_profiles") - self.fields["delegates"].widget.search_url = reverse("staff:fetch_user_profiles") + 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( @@ -1153,21 +1145,21 @@ def save(self, *args, **kw): class UserMergeSelectionForm(forms.Form): - main_user = UserModelChoiceField(UserProfile.objects.all(), widget=ServerSearchSelect()) - other_user = UserModelChoiceField(UserProfile.objects.all(), widget=ServerSearchSelect()) + 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.search_url = reverse("staff:fetch_user_profiles") - self.fields["other_user"].widget.search_url = reverse("staff:fetch_user_profiles") + 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(), widget=ServerSearchSelect()) + user = UserModelChoiceField(UserProfile.objects.all()) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["user"].widget.search_url = reverse("staff:fetch_user_profiles") + self.fields["user"].widget.options_endpoint = reverse("staff:user_profile_options") class FaqSectionForm(forms.ModelForm): diff --git a/evap/staff/urls.py b/evap/staff/urls.py index aa630195e2..06e74ad2ea 100644 --- a/evap/staff/urls.py +++ b/evap/staff/urls.py @@ -102,12 +102,12 @@ 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.AllUserProfileSearchView.as_view(), - name="fetch_user_profiles"), + path("user_profiles/", views.AllUserProfileOptionsView.as_view(), + name="user_profile_options"), path("user_profiles/participants/", views.ParticipantsUserProfileSearchView.as_view(), - name="fetch_participants_user_profiles"), + name="participant_options"), path("user_profiles/participants/", views.ParticipantsUserProfileSearchView.as_view(), - name="fetch_participants_user_profiles"), + name="participant_options"), path("user_profiles/contributor/", views.ContributorUserProfileSearchView.as_view(), name="fetch_contributor_user_profiles"), ] diff --git a/evap/staff/views.py b/evap/staff/views.py index d395598eb3..63b8f6952a 100644 --- a/evap/staff/views.py +++ b/evap/staff/views.py @@ -75,7 +75,7 @@ sort_formset, temporary_receiver, ) -from evap.evaluation.views import UserProfileSearchView +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 @@ -2734,7 +2734,7 @@ def exit_staff_mode(request): @staff_permission_required -class ParticipantsUserProfileSearchView(UserProfileSearchView): +class ParticipantsUserProfileSearchView(UserProfileOptionsBaseView): def search(self, query, request, *args, **kwargs) -> QuerySet[UserProfile]: evaluation = get_object_or_404(Evaluation, id=kwargs["evaluation"]) if kwargs.get("evaluation") else None return ( @@ -2745,7 +2745,7 @@ def search(self, query, request, *args, **kwargs) -> QuerySet[UserProfile]: @staff_permission_required -class ContributorUserProfileSearchView(UserProfileSearchView): +class ContributorUserProfileSearchView(UserProfileOptionsBaseView): def search(self, query, request, *args, **kwargs) -> QuerySet[UserProfile]: return ( super().search(query, request, *args, **kwargs).filter(pk__in=ContributionForm.get_contributor_queryset()) @@ -2753,5 +2753,5 @@ def search(self, query, request, *args, **kwargs) -> QuerySet[UserProfile]: @staff_permission_required -class AllUserProfileSearchView(UserProfileSearchView): +class AllUserProfileOptionsView(UserProfileOptionsBaseView): pass From a85aabfc82d49272bbac16754ad07b409bb0a941 Mon Sep 17 00:00:00 2001 From: Jonathan Weth Date: Mon, 4 May 2026 20:06:42 +0200 Subject: [PATCH 05/11] Fix more things for user profile options --- evap/contributor/views.py | 10 ++++++---- evap/evaluation/templates/base.html | 2 +- evap/evaluation/views.py | 31 +++++++++-------------------- evap/staff/urls.py | 2 +- evap/staff/views.py | 14 ++++++------- 5 files changed, 24 insertions(+), 35 deletions(-) diff --git a/evap/contributor/views.py b/evap/contributor/views.py index ad34cdc050..1f5bc8c9eb 100644 --- a/evap/contributor/views.py +++ b/evap/contributor/views.py @@ -309,16 +309,18 @@ def export(request): @responsible_or_contributor_or_delegate_required class DelegateOptionsView(UserProfileOptionsBaseView): - def search(self, query, request, *args, **kwargs) -> QuerySet[UserProfile]: - return super().search(query, request, *args, **kwargs).filter(pk__in=UserProfile.objects.get_delegates()) + @classmethod + def get_queryset(cls, request, *args, **kwargs) -> QuerySet[UserProfile]: + return super().get_queryset(request, *args, **kwargs).filter(pk__in=UserProfile.objects.get_delegates()) @responsible_or_contributor_or_delegate_required class ParticipantOptionsView(UserProfileOptionsBaseView): - def search(self, query, request, *args, **kwargs) -> QuerySet[UserProfile]: + @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() - .search(query, request, *args, **kwargs) + .get_queryset(request, *args, **kwargs) .filter(pk__in=EvaluationForm.get_participants_queryset(evaluation)) ) diff --git a/evap/evaluation/templates/base.html b/evap/evaluation/templates/base.html index 9701135060..c1dc83075f 100644 --- a/evap/evaluation/templates/base.html +++ b/evap/evaluation/templates/base.html @@ -181,7 +181,7 @@ .then(json => { callback(json.options); }).catch(() => { - callback(); + alert(window.gettext("The server is not responding.")); }); }, valueField: 'id', diff --git a/evap/evaluation/views.py b/evap/evaluation/views.py index d27a5460ac..bda08350b1 100644 --- a/evap/evaluation/views.py +++ b/evap/evaluation/views.py @@ -1,12 +1,11 @@ import logging -from abc import abstractmethod from datetime import date, timedelta from django.conf import settings from django.contrib import auth, messages from django.core.exceptions import BadRequest, SuspiciousOperation from django.core.mail import EmailMessage -from django.db.models import Model, Q, QuerySet +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 @@ -252,32 +251,20 @@ def set_startpage(request: HttpRequest) -> HttpResponse: return redirect("evaluation:index") -class ServerSideSelectOptionsBaseView[T: Model](View): +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") - items = self.search(query, request, *args, **kwargs) - options = [{"id": item.pk, "text": self.get_item_label(item)} for item in items] - return JsonResponse({"options": options}) - - @abstractmethod - def get_item_label(self, item: T) -> str: - pass - - @abstractmethod - def search(self, query: str, request: HttpRequest, *args, **kwargs) -> QuerySet[T]: - pass - - -class UserProfileOptionsBaseView(ServerSideSelectOptionsBaseView[UserProfile]): - def get_item_label(self, item) -> str: - return item.full_name_with_additional_info - - def search(self, query, request, *args, **kwargs): - return UserProfile.objects.filter( + items = self.get_queryset(request).filter( Q(first_name_given__icontains=query) | Q(first_name_chosen__icontains=query) | Q(last_name__icontains=query) | Q(email__icontains=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/urls.py b/evap/staff/urls.py index 06e74ad2ea..d827aba2b6 100644 --- a/evap/staff/urls.py +++ b/evap/staff/urls.py @@ -106,7 +106,7 @@ name="user_profile_options"), path("user_profiles/participants/", views.ParticipantsUserProfileSearchView.as_view(), name="participant_options"), - path("user_profiles/participants/", views.ParticipantsUserProfileSearchView.as_view(), + path("user_profiles/participants/", views.ParticipantsUserProfileSearchView.as_view(), name="participant_options"), path("user_profiles/contributor/", views.ContributorUserProfileSearchView.as_view(), name="fetch_contributor_user_profiles"), diff --git a/evap/staff/views.py b/evap/staff/views.py index 63b8f6952a..6a3c5392e8 100644 --- a/evap/staff/views.py +++ b/evap/staff/views.py @@ -2735,21 +2735,21 @@ def exit_staff_mode(request): @staff_permission_required class ParticipantsUserProfileSearchView(UserProfileOptionsBaseView): - def search(self, query, request, *args, **kwargs) -> QuerySet[UserProfile]: - evaluation = get_object_or_404(Evaluation, id=kwargs["evaluation"]) if kwargs.get("evaluation") else None + @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() - .search(query, request, *args, **kwargs) + .get_queryset(request, *args, **kwargs) .filter(pk__in=EvaluationForm.get_participants_queryset(evaluation)) ) @staff_permission_required class ContributorUserProfileSearchView(UserProfileOptionsBaseView): - def search(self, query, request, *args, **kwargs) -> QuerySet[UserProfile]: - return ( - super().search(query, request, *args, **kwargs).filter(pk__in=ContributionForm.get_contributor_queryset()) - ) + @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 From 23734a6d1fbf84cebd70ef82d779c28a4f769258 Mon Sep 17 00:00:00 2001 From: Jonathan Weth Date: Mon, 4 May 2026 21:19:08 +0200 Subject: [PATCH 06/11] Use fuzzy matching for searching user profiles --- evap/evaluation/migrations/0165_trigram.py | 10 ++++++++++ evap/evaluation/views.py | 8 ++++---- 2 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 evap/evaluation/migrations/0165_trigram.py 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/views.py b/evap/evaluation/views.py index bda08350b1..e923574024 100644 --- a/evap/evaluation/views.py +++ b/evap/evaluation/views.py @@ -261,10 +261,10 @@ def get(self, request: HttpRequest, *args, **kwargs) -> JsonResponse: if query is None: raise BadRequest("Please provide a search query") items = self.get_queryset(request).filter( - Q(first_name_given__icontains=query) - | Q(first_name_chosen__icontains=query) - | Q(last_name__icontains=query) - | Q(email__icontains=query) + 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}) From 7fc093b59d267a7e05dcd8750b35d9c232c5fa9b Mon Sep 17 00:00:00 2001 From: Jonathan Weth Date: Mon, 29 Jun 2026 22:33:15 +0200 Subject: [PATCH 07/11] Add tests for user profile select --- evap/contributor/forms.py | 2 +- evap/contributor/tests/test_live.py | 72 ++++++++++++++++++++- evap/contributor/views.py | 2 +- evap/evaluation/forms.py | 2 +- evap/evaluation/models.py | 2 +- evap/evaluation/tests/test_live.py | 34 +++++++++- evap/evaluation/tests/tools.py | 35 ++++++++++- evap/staff/tests/test_live.py | 98 ++++++++++++++++++++++++++++- 8 files changed, 239 insertions(+), 8 deletions(-) diff --git a/evap/contributor/forms.py b/evap/contributor/forms.py index 40f628972e..e80cebc9c9 100644 --- a/evap/contributor/forms.py +++ b/evap/contributor/forms.py @@ -166,7 +166,7 @@ def __init__(self, *args, **kwargs): class DelegateSelectionForm(forms.Form): - delegate_to = UserModelChoiceField(label=_("Delegate to"), queryset=UserProfile.objects.get_delegates()) + delegate_to = UserModelChoiceField(label=_("Delegate to"), queryset=UserProfile.objects.get_delegate_options()) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/evap/contributor/tests/test_live.py b/evap/contributor/tests/test_live.py index c2ad7182d5..72a4251f7e 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): @@ -49,3 +49,73 @@ def test_delegation_modal(self): evaluation=evaluation, contributor__email="manager@institution.example.com" ).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/views.py b/evap/contributor/views.py index 1f5bc8c9eb..df20c3a41d 100644 --- a/evap/contributor/views.py +++ b/evap/contributor/views.py @@ -311,7 +311,7 @@ def export(request): 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_delegates()) + return super().get_queryset(request, *args, **kwargs).filter(pk__in=UserProfile.objects.get_delegate_options()) @responsible_or_contributor_or_delegate_required diff --git a/evap/evaluation/forms.py b/evap/evaluation/forms.py index 0361d4150c..985c2f4111 100644 --- a/evap/evaluation/forms.py +++ b/evap/evaluation/forms.py @@ -133,7 +133,7 @@ def label_from_instance(self, obj: UserProfile) -> str: class ProfileForm(forms.ModelForm): delegates = UserModelMultipleChoiceField( - queryset=UserProfile.objects.get_delegates(), + queryset=UserProfile.objects.get_delegate_options(), required=False, ) diff --git a/evap/evaluation/models.py b/evap/evaluation/models.py index 8d975699b7..1558b821e3 100644 --- a/evap/evaluation/models.py +++ b/evap/evaluation/models.py @@ -1822,7 +1822,7 @@ def create_superuser(self, *, email, password=None, first_name_given=None, last_ user.groups.add(Group.objects.get(name="Manager")) return user - def get_delegates(self): + def get_delegate_options(self): return self.exclude(is_active=False).exclude(is_proxy_user=True) 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/tools.py b/evap/evaluation/tests/tools.py index 5fd6de2e57..a5fe90aacb 100644 --- a/evap/evaluation/tests/tools.py +++ b/evap/evaluation/tests/tools.py @@ -25,7 +25,7 @@ from selenium.webdriver.common.by import By 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 ( @@ -421,3 +421,36 @@ 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: + input_field = self.selenium.find_element(By.ID, f"id_{field_name}-ts-control") + input_field.clear() + input_field.send_keys(getattr(user, field)) + + found_item = self.wait.until(presence_of_element_located((By.ID, f"id_{field_name}-opt-1"))) + 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_item.get_attribute("data-value"), str(user.pk)) + found_item.click() + + 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/staff/tests/test_live.py b/evap/staff/tests/test_live.py index f0d49e32ad..4dfcd6a9a5 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): @@ -334,3 +334,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], []) From 46fa8a69bfbaf7d30b7adc026412863eaec1322e Mon Sep 17 00:00:00 2001 From: Jonathan Weth Date: Mon, 6 Jul 2026 17:52:27 +0200 Subject: [PATCH 08/11] Solve more review comments --- evap/evaluation/templates/base.html | 7 ++----- evap/evaluation/views.py | 17 ++++++++++------- evap/staff/urls.py | 6 +++--- evap/staff/views.py | 4 ++-- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/evap/evaluation/templates/base.html b/evap/evaluation/templates/base.html index c1dc83075f..d1660785f3 100644 --- a/evap/evaluation/templates/base.html +++ b/evap/evaluation/templates/base.html @@ -178,11 +178,8 @@ 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.")); - }); + .then(json => callback(json.options)) + .catch(() => alert(window.gettext("The server is not responding."))); }, valueField: 'id', labelField: 'text', diff --git a/evap/evaluation/views.py b/evap/evaluation/views.py index e923574024..a93b9911bb 100644 --- a/evap/evaluation/views.py +++ b/evap/evaluation/views.py @@ -260,11 +260,14 @@ def get(self, request: HttpRequest, *args, **kwargs) -> JsonResponse: query = request.GET.get("query") if query is None: raise BadRequest("Please provide a search query") - 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] + 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/urls.py b/evap/staff/urls.py index d827aba2b6..0e7f61d037 100644 --- a/evap/staff/urls.py +++ b/evap/staff/urls.py @@ -104,10 +104,10 @@ path("user_profiles/", views.AllUserProfileOptionsView.as_view(), name="user_profile_options"), - path("user_profiles/participants/", views.ParticipantsUserProfileSearchView.as_view(), + path("user_profiles/participants/", views.ParticipantsUserProfileOptionsView.as_view(), name="participant_options"), - path("user_profiles/participants/", views.ParticipantsUserProfileSearchView.as_view(), + path("user_profiles/participants/", views.ParticipantsUserProfileOptionsView.as_view(), name="participant_options"), - path("user_profiles/contributor/", views.ContributorUserProfileSearchView.as_view(), + 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 6a3c5392e8..0bbc79f2a3 100644 --- a/evap/staff/views.py +++ b/evap/staff/views.py @@ -2734,7 +2734,7 @@ def exit_staff_mode(request): @staff_permission_required -class ParticipantsUserProfileSearchView(UserProfileOptionsBaseView): +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 @@ -2746,7 +2746,7 @@ def get_queryset(cls, request, *args, **kwargs) -> QuerySet[UserProfile]: @staff_permission_required -class ContributorUserProfileSearchView(UserProfileOptionsBaseView): +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()) From f0eb866d7317b4dee05a38abf82aab541144876d Mon Sep 17 00:00:00 2001 From: Jonathan Weth Date: Mon, 6 Jul 2026 18:37:50 +0200 Subject: [PATCH 09/11] Fix some tests --- evap/evaluation/tests/test_views.py | 2 +- evap/staff/forms.py | 6 ++++-- evap/staff/tests/test_views.py | 10 +++++----- 3 files changed, 10 insertions(+), 8 deletions(-) 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/staff/forms.py b/evap/staff/forms.py index 516a3c8370..23635de011 100644 --- a/evap/staff/forms.py +++ b/evap/staff/forms.py @@ -468,8 +468,10 @@ def __init__(self, *args, requires_decided_main_language=False, **kwargs): ) self.fields["participants"].queryset = self.get_participants_queryset(self.instance) - self.fields["participants"].widget.options_endpoint = reverse( - "staff:participant_options", args=[self.instance.pk] + 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: 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 From fafdf1991b0d4640c110a03ca549a5d2a9f448ee Mon Sep 17 00:00:00 2001 From: Jonathan Weth Date: Mon, 6 Jul 2026 22:42:12 +0200 Subject: [PATCH 10/11] Try to fix live server tests --- evap/contributor/tests/test_live.py | 20 +++-------------- evap/evaluation/tests/tools.py | 34 +++++++++++++++++++++++------ evap/staff/tests/test_live.py | 19 ++++------------ 3 files changed, 34 insertions(+), 39 deletions(-) diff --git a/evap/contributor/tests/test_live.py b/evap/contributor/tests/test_live.py index 72a4251f7e..94241b9385 100644 --- a/evap/contributor/tests/test_live.py +++ b/evap/contributor/tests/test_live.py @@ -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,23 +28,13 @@ 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): diff --git a/evap/evaluation/tests/tools.py b/evap/evaluation/tests/tools.py index a5fe90aacb..01c07b10a3 100644 --- a/evap/evaluation/tests/tools.py +++ b/evap/evaluation/tests/tools.py @@ -22,10 +22,14 @@ from django.urls import reverse from django.utils import timezone, translation from model_bakery import baker +from selenium.webdriver import Keys from selenium.webdriver.common.by import By from selenium.webdriver.firefox.webdriver import WebDriver from selenium.webdriver.remote.webelement import WebElement -from selenium.webdriver.support.expected_conditions import presence_of_element_located, 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")) + ).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() @@ -431,16 +455,12 @@ def conduct_user_profile_search_test( for user in users_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.search_and_select_in_tom_select(field_name, getattr(user, field), clear=True) - found_item = self.wait.until(presence_of_element_located((By.ID, f"id_{field_name}-opt-1"))) 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_item.get_attribute("data-value"), str(user.pk)) - found_item.click() + 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)) diff --git a/evap/staff/tests/test_live.py b/evap/staff/tests/test_live.py index 4dfcd6a9a5..578e3a7d58 100644 --- a/evap/staff/tests/test_live.py +++ b/evap/staff/tests/test_live.py @@ -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( From 35fc6831d67448e1d8f73da734e857cb3622ab31 Mon Sep 17 00:00:00 2001 From: Jonathan Weth Date: Mon, 13 Jul 2026 21:52:10 +0200 Subject: [PATCH 11/11] Fix tests --- evap/evaluation/tests/tools.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/evap/evaluation/tests/tools.py b/evap/evaluation/tests/tools.py index 01c07b10a3..0efd4f1792 100644 --- a/evap/evaluation/tests/tools.py +++ b/evap/evaluation/tests/tools.py @@ -22,8 +22,8 @@ from django.urls import reverse from django.utils import timezone, translation from model_bakery import baker -from selenium.webdriver import Keys 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 ( @@ -401,9 +401,9 @@ def wait_until_page_reloads(self): 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")) - ).click() + 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(