From efe8057051d9da83d4f0ce59f5e8d0b891b0d398 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:17:38 +0000 Subject: [PATCH 1/3] Initial plan From f8e6438aebece0d4c3c0dd80ede41e81084d9e4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:54:09 +0000 Subject: [PATCH 2/3] Migrate from Select2 to TomSelect using django-tomselect Co-authored-by: inducer <352067+inducer@users.noreply.github.com> --- course/auth.py | 55 +++++++++++++++++++--------- course/enrollment.py | 5 --- course/exam.py | 7 +--- course/flow.py | 4 +- course/versioning.py | 2 - course/views.py | 10 ++--- frontend/css/base.scss | 2 +- frontend/js/base.js | 14 +++++-- package-lock.json | 77 ++++++++++++++++++++++++++++----------- package.json | 3 +- pyproject.toml | 2 +- relate/settings.py | 7 +--- relate/urls.py | 5 ++- tests/base_test_mixins.py | 41 ++++++++++++++++----- tests/test_auth.py | 20 +++++----- tests/test_exam.py | 6 +-- 16 files changed, 163 insertions(+), 97 deletions(-) diff --git a/course/auth.py b/course/auth.py index b32e2a73f..8af1b8fd3 100644 --- a/course/auth.py +++ b/course/auth.py @@ -63,7 +63,9 @@ from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_protect from django.views.decorators.debug import sensitive_post_parameters -from django_select2.forms import ModelSelect2Widget +from django_tomselect.app_settings import TomSelectConfig +from django_tomselect.autocompletes import AutocompleteModelView +from django_tomselect.forms import TomSelectModelChoiceField from djangosaml2.backends import Saml2Backend from accounts.models import User @@ -176,38 +178,55 @@ def __call__(self, request): return self.get_response(request) -class UserSearchWidget(ModelSelect2Widget): +class UserAutocompleteView(AutocompleteModelView): + """Autocomplete view for user search in the impersonation form.""" + model = User - search_fields = [ + search_lookups = [ "username__icontains", "email__icontains", "first_name__icontains", "last_name__icontains", ] - - def label_from_instance(self, u): - if u.first_name and u.last_name: - return ( - f"{u.get_full_name()} ({u.username} - {u.email})") - else: - # for users with "None" fullname - return ( - f"{u.username} ({u.email})") + value_fields = ["id", "username", "email", "first_name", "last_name"] + virtual_fields = ["label"] + + def get_queryset(self): + qset = get_impersonable_user_qset(cast("User", self.request.user)) + queryset = (User.objects + .filter(pk__in=qset.values_list("pk", flat=True)) + .order_by("last_name", "first_name", "username")) + return self.search(queryset, self.query) + + def hook_prepare_results(self, results): + prepared = [] + for item in results: + if item.get("first_name") and item.get("last_name"): + label = ( + f"{item['first_name']} {item['last_name']}" + f" ({item['username']} - {item['email']})") + else: + label = f"{item['username']} ({item['email']})" + item["label"] = label + prepared.append(item) + return prepared class ImpersonateForm(StyledForm): def __init__(self, *args: Any, **kwargs: Any) -> None: - qset = kwargs.pop("impersonable_qset") + kwargs.pop("impersonable_qset") super().__init__(*args, **kwargs) - self.fields["user"] = forms.ModelChoiceField( - queryset=qset, + self.fields["user"] = TomSelectModelChoiceField( required=True, help_text=_("Select user to impersonate."), - widget=UserSearchWidget( - queryset=qset, - attrs={"data-minimum-input-length": 0}, + config=TomSelectConfig( + url="user-autocomplete", + value_field="id", + label_field="label", + minimum_query_length=0, + preload=True, ), label=_("User")) diff --git a/course/enrollment.py b/course/enrollment.py index c0c230247..8c723d485 100644 --- a/course/enrollment.py +++ b/course/enrollment.py @@ -44,7 +44,6 @@ from django.utils.translation import gettext_lazy as _, pgettext from pytools.lex import RE, LexTable -from course.auth import UserSearchWidget from course.constants import ( PARTICIPATION_PERMISSION_CHOICES, ParticipationPermission as PPerm, @@ -1001,10 +1000,6 @@ class Meta: "course", ) - widgets = { - "user": UserSearchWidget, - } - @course_view def edit_participation( diff --git a/course/exam.py b/course/exam.py index 2cfe32f49..79bb98d77 100644 --- a/course/exam.py +++ b/course/exam.py @@ -98,13 +98,10 @@ def __init__(self, now_datetime, *args, **kwargs): super().__init__(*args, **kwargs) - from course.auth import UserSearchWidget - self.fields["user"] = forms.ModelChoiceField( queryset=(get_user_model().objects .filter(is_active=True) .order_by("last_name")), - widget=UserSearchWidget(), required=True, help_text=_("Select participant for whom ticket is to " "be issued."), @@ -774,7 +771,7 @@ def __call__(self, request: http.HttpRequest) -> http.HttpResponse: sign_out, set_pretend_facilities] or request.path.startswith("/saml2") - or request.path.startswith("/select2") + or request.path.startswith("/user-autocomplete") or ((request.user.is_staff or request.user.has_perm("course.can_issue_exam_tickets")) and resolver_match.func == issue_exam_ticket)): @@ -857,7 +854,7 @@ def __call__(self, request: RelateHttpRequest): user_profile, sign_out] or request.path.startswith("/saml2") - or request.path.startswith("/select2") + or request.path.startswith("/user-autocomplete") or ( resolver_match.func in [ view_resume_flow, diff --git a/course/flow.py b/course/flow.py index 1f13f00d0..c26ea91a2 100644 --- a/course/flow.py +++ b/course/flow.py @@ -43,7 +43,6 @@ from django.urls import reverse from django.utils.safestring import mark_safe from django.utils.translation import gettext, gettext_lazy as _ -from django_select2.forms import Select2Widget from pytools import not_none import course.utils as c_utils # These are mocked, so import as module @@ -2825,8 +2824,7 @@ def __init__(self, flow_ids: list[str], *args: Any, **kwargs: Any) -> None: self.fields["flow_id"] = forms.ChoiceField( choices=[(fid, fid) for fid in flow_ids], required=True, - label=_("Flow ID"), - widget=Select2Widget()) + label=_("Flow ID")) self.fields["access_rules_tag"] = forms.CharField( required=False, help_text=_("If non-empty, limit the regrading to sessions " diff --git a/course/versioning.py b/course/versioning.py index 128da1edf..3ee0f4da1 100644 --- a/course/versioning.py +++ b/course/versioning.py @@ -54,7 +54,6 @@ pgettext_lazy, ) from django.views.decorators.csrf import csrf_exempt -from django_select2.forms import Select2Widget from dulwich.repo import Repo from course.auth import with_course_api_auth @@ -473,7 +472,6 @@ def format_sha(sha): for entry in commit_iter ]), required=True, - widget=Select2Widget(), label=pgettext_lazy( "new git SHA for revision of course contents", "New git SHA")) diff --git a/course/views.py b/course/views.py index b9a54c211..67d92723b 100644 --- a/course/views.py +++ b/course/views.py @@ -49,7 +49,6 @@ pgettext_lazy, ) from django.views.decorators.cache import cache_control -from django_select2.forms import Select2Widget from course.auth import get_pre_impersonation_user from course.constants import ( @@ -618,8 +617,7 @@ def __init__(self, flow_ids, *args, **kwargs): self.fields["flow_id"] = forms.ChoiceField( choices=[(fid, fid) for fid in flow_ids], required=True, - label=_("Flow ID"), - widget=Select2Widget()) + label=_("Flow ID")) self.fields["duration_in_minutes"] = forms.IntegerField( required=True, initial=20, label=pgettext_lazy("Duration for instant flow", @@ -699,8 +697,7 @@ def __init__(self, flow_ids, *args, **kwargs): self.fields["flow_id"] = forms.ChoiceField( choices=[(fid, fid) for fid in flow_ids], required=True, - label=_("Flow ID"), - widget=Select2Widget()) + label=_("Flow ID")) self.helper.add_input( Submit( @@ -765,8 +762,7 @@ def __init__(self, course, flow_ids, *args, **kwargs): required=True, help_text=_("Select participant for whom exception is to " "be granted."), - label=_("Participant"), - widget=Select2Widget()) + label=_("Participant")) self.fields["flow_id"] = forms.ChoiceField( choices=[(fid, fid) for fid in flow_ids], required=True, diff --git a/frontend/css/base.scss b/frontend/css/base.scss index 67a7274d9..c2783466d 100644 --- a/frontend/css/base.scss +++ b/frontend/css/base.scss @@ -1,7 +1,7 @@ $color-mode-type: media-query; @import "node_modules/bootstrap/scss/bootstrap.scss"; -@import "node_modules/select2/dist/css/select2"; +@import "node_modules/tom-select/dist/scss/tom-select.bootstrap5"; // work around spurious extra quoting in woff include $bootstrap-icons-font-file: "./fonts/bootstrap-icons"; diff --git a/frontend/js/base.js b/frontend/js/base.js index 73228a4aa..f8fb7cebb 100644 --- a/frontend/js/base.js +++ b/frontend/js/base.js @@ -2,7 +2,7 @@ import './jquery-importer'; import jQuery from 'jquery'; import * as bootstrap from 'bootstrap'; -import select2 from 'select2'; +import TomSelect from 'tom-select'; import * as rlUtils from './rlUtils'; import * as bsUtils from './bsUtils'; @@ -10,9 +10,14 @@ import 'htmx.org'; import '../css/base.scss'; -select2(jQuery); - document.addEventListener('DOMContentLoaded', () => { + // Initialize TomSelect on all plain