Skip to content
29 changes: 20 additions & 9 deletions evap/contributor/forms.py
Comment thread
hansegucker marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 = [
Expand All @@ -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()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had hoped we would not need this anymore (by just passing the initial options in HTML) -- why is this still needed here?

return queryset

def clean(self):
super().clean()

Expand Down Expand Up @@ -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")
90 changes: 73 additions & 17 deletions evap/contributor/tests/test_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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"))

Expand All @@ -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])
6 changes: 5 additions & 1 deletion evap/contributor/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,9 @@
path("evaluation/<int:evaluation_id>", views.evaluation_view, name="evaluation_view"),
path("evaluation/<int:evaluation_id>/edit", views.evaluation_edit, name="evaluation_edit"),
path("evaluation/<int:evaluation_id>/preview", views.evaluation_preview, name="evaluation_preview"),
path("evaluation/<int:evaluation_id>/direct_delegation", views.evaluation_direct_delegation, name="evaluation_direct_delegation")
path("evaluation/<int:evaluation_id>/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"),
]
22 changes: 21 additions & 1 deletion evap/contributor/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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))
)
33 changes: 28 additions & 5 deletions evap/evaluation/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"]

Comment thread
hansegucker marked this conversation as resolved.
@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."""

Expand Down Expand Up @@ -96,33 +118,34 @@ 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


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)
Expand Down
10 changes: 10 additions & 0 deletions evap/evaluation/migrations/0165_trigram.py
Original file line number Diff line number Diff line change
@@ -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()]
3 changes: 3 additions & 0 deletions evap/evaluation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
22 changes: 18 additions & 4 deletions evap/evaluation/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) => `<div class="create">${ escape(data.input) }</div>`,
Expand All @@ -169,8 +169,22 @@
} else {
return '<div class="no-results">{% translate "No results found" %}</div>';
}
}
},
not_loading: (data, escape) => `<div class="no-results">{% translate "Please enter ${ minimumInputLength } characters or more..." %}</div>`
},
...(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,
Expand Down Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<select class="form-select 2" name="{{ widget.name }}"{% include 'django/forms/widgets/attrs.html' %} autocomplete="off">
{# 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 %}
<optgroup label="{{ group_name }}">
{% endif %}
{% for option in group_choices %}{% if option.selected %}{% include option.template_name with widget=option %}{% endif %}{% endfor %}
{% if group_name %}
</optgroup>
{% endif %}
{% endfor %}
Comment thread
hansegucker marked this conversation as resolved.
</select>
Loading
Loading