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_delegates())

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["delegate_to"].widget.options_endpoint = reverse("contributor:delegate_options")
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_delegates())


@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_delegates(),
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_delegates(self):
return self.exclude(is_active=False).exclude(is_proxy_user=True)
Comment thread
hansegucker marked this conversation as resolved.
Outdated


assert settings.AUTH_PASSWORD_VALIDATORS == [], "Password validation configured, but evap will not apply it"

Expand Down
25 changes: 21 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,25 @@
} 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(() => {
Comment thread
hansegucker marked this conversation as resolved.
Outdated
alert(window.gettext("The server is not responding."));
});
Comment thread
hansegucker marked this conversation as resolved.
Outdated
},
valueField: 'id',
labelField: 'text',
searchField: 'text'
} : {}),
closeAfterSelect: true, // also clears search input on enter.
plugins: {},
hideSelected: false,
Expand Down Expand Up @@ -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));
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>
25 changes: 23 additions & 2 deletions evap/evaluation/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -247,3 +249,22 @@ def set_startpage(request: HttpRequest) -> HttpResponse:
user.save()

return redirect("evaluation:index")


class UserProfileOptionsBaseView(View):
Comment thread
hansegucker marked this conversation as resolved.
@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.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})
Loading
Loading