Merge pull request #36609 from musanaeem/musa/admin-revamp-dropdowns

Single Select Autocomplete Added to Student Admin
This commit is contained in:
Feanil Patel
2025-06-04 11:37:29 -04:00
committed by GitHub
10 changed files with 277 additions and 4 deletions

View File

@@ -2,6 +2,9 @@
from functools import wraps
from dal_select2.views import Select2ListView
from dal_select2.widgets import ListSelect2
from django_countries import countries
from config_models.admin import ConfigurationModelAdmin
from django import forms
@@ -11,12 +14,14 @@ from django.contrib.admin.sites import NotRegistered
from django.contrib.admin.utils import unquote
from django.contrib.auth import get_user_model
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import ReadOnlyPasswordHashField
from django.contrib.auth.forms import UserChangeForm as BaseUserChangeForm
from django.db import models, router, transaction
from django.http import HttpResponseRedirect
from django.http.request import QueryDict
from django.urls import reverse
from django.urls import reverse, path
from django.utils.decorators import method_decorator
from django.utils.translation import ngettext
from django.utils.translation import gettext_lazy as _
from opaque_keys import InvalidKeyError
@@ -45,6 +50,7 @@ from common.djangoapps.student.models import (
UserProfile,
UserTestGroup
)
from common.djangoapps.student.constants import LANGUAGE_CHOICES
from common.djangoapps.student.roles import REGISTERED_ACCESS_ROLES
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
@@ -309,9 +315,81 @@ class CourseEnrollmentAdmin(DisableEnrollmentAdminMixin, admin.ModelAdmin):
return super().get_queryset(request).select_related('user') # lint-amnesty, pylint: disable=no-member, super-with-arguments
@method_decorator(login_required, name='dispatch')
class LanguageAutocomplete(Select2ListView):
def get_list(self):
if not self.request.user.is_staff:
return []
return [lang for lang in LANGUAGE_CHOICES if self.q.lower() in lang.lower()]
@method_decorator(login_required, name='dispatch')
class CountryAutocomplete(Select2ListView):
"""
Autocomplete view for selecting countries using Select2.
Only accessible to authenticated staff users. Filters the list of countries
based on the user input (query string) and returns matching results.
"""
def get_list(self):
"""
Returns a filtered list of country tuples (code, name) based on the query.
"""
if not self.request.user.is_staff:
return []
results = []
for code, name in countries:
if self.q.lower() in name.lower():
results.append((code, name))
return results
def get_result_label(self, item):
""" What the user sees in the dropdown """
return dict(countries).get(item, item)
def get_result_value(self, item):
""" What gets sent back on selection (the code) """
return item
class UserProfileInlineForm(forms.ModelForm):
"""
A custom form for editing the UserProfile model within the admin inline.
"""
language = forms.CharField(
required=False,
widget=ListSelect2(url='admin:language-autocomplete') # pylint: disable=no-member
)
country = forms.CharField(
required=False,
widget=ListSelect2(url='admin:country-autocomplete') # pylint: disable=no-member
)
class Meta:
model = UserProfile
fields = '__all__'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance and self.instance.pk:
if self.instance.country:
code = self.instance.country
name = countries.name(code) if code in countries else code
self.fields['country'].widget.choices = [(code, name)]
self.initial['country'] = code
if self.instance.language:
language = self.instance.language
self.fields['language'].initial = language
self.fields['language'].widget.choices = [(language, language)]
class UserProfileInline(admin.StackedInline):
""" Inline admin interface for UserProfile model. """
model = UserProfile
form = UserProfileInlineForm
can_delete = False
verbose_name_plural = _('User profile')
@@ -359,6 +437,18 @@ class UserAdmin(BaseUserAdmin):
return django_readonly + ('username',)
return django_readonly
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path(
'language-autocomplete/',
LanguageAutocomplete.as_view(),
name='language-autocomplete'
),
path('country-autocomplete/', CountryAutocomplete.as_view(), name='country-autocomplete'),
]
return custom_urls + urls
@admin.register(UserAttribute)
class UserAttributeAdmin(admin.ModelAdmin):

View File

@@ -0,0 +1,4 @@
"""# Generate a sorted list of unique language names from pycountry """
import pycountry
LANGUAGE_CHOICES = sorted({lang.name for lang in pycountry.languages if hasattr(lang, 'alpha_2')})

View File

@@ -4,10 +4,12 @@ Tests student admin.py
import datetime
import json
from unittest.mock import Mock
import ddt
import pytest
from django.contrib.admin.sites import AdminSite
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.forms import ValidationError
@@ -24,7 +26,7 @@ from common.djangoapps.student.admin import ( # lint-amnesty, pylint: disable=l
UserAdmin
)
from common.djangoapps.student.models import AllowedAuthUser, CourseEnrollment, LoginFailures
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory, UserProfileFactory
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
@@ -532,3 +534,164 @@ class AllowedAuthUserFormTest(SiteMixin, TestCase):
db_allowed_auth_user = AllowedAuthUser.objects.all().first()
assert AllowedAuthUser.objects.all().count() == 1
assert db_allowed_auth_user.email == self.other_valid_email
@ddt.ddt
class TestUserProfileAutocompleteAdmin(TestCase):
"""Tests for language and country autocomplete in UserProfile inline form via Django admin."""
def setUp(self):
super().setUp()
self.staff_user = UserFactory(is_staff=True)
self.staff_user.set_password('test')
self.staff_user.save()
self.non_staff_user = UserFactory(is_staff=False)
self.non_staff_user.set_password('test')
self.non_staff_user.save()
self.client.login(username=self.staff_user.username, password='test')
self.language_url = reverse('admin:language-autocomplete')
self.country_url = reverse('admin:country-autocomplete')
user1 = UserFactory()
user1.set_password('test')
user1.save()
UserProfileFactory(user=user1, language='English', country='PK')
user2 = UserFactory()
user2.set_password('test')
user2.save()
UserProfileFactory(user=user2, language='French', country='GB')
user3 = UserFactory()
user3.set_password('test')
user3.save()
UserProfileFactory(user=user3, language='German', country='US')
def test_language_autocomplete_returns_expected_result(self):
"""Verify language autocomplete returns expected filtered results."""
profile = UserProfileFactory(user=self.staff_user, language='Esperanto')
response = self.client.get(self.language_url)
self.assertEqual(response.status_code, 200)
data = json.loads(response.content.decode('utf-8'))
self.assertTrue(
any('Esperanto' in item['text'] for item in data['results']),
f"Esperanto not found in: {data['results']}"
)
profile.language = 'French'
profile.save()
response = self.client.get(f'{self.language_url}?q=Fren')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content.decode('utf-8'))
self.assertTrue(
any('French' in item['text'] for item in data['results']),
f"French not found in: {data['results']}"
)
def test_country_autocomplete_returns_expected_result(self):
"""Verify country autocomplete returns expected filtered results."""
profile = UserProfileFactory(user=self.staff_user, country='SE')
response = self.client.get(self.country_url)
self.assertEqual(response.status_code, 200)
data = json.loads(response.content.decode('utf-8'))
self.assertTrue(
any('Sweden' in item['text'] for item in data['results']),
f"Sweden not found in: {data['results']}"
)
profile.country = 'JP'
profile.save()
response = self.client.get(f'{self.country_url}?q=Japan')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content.decode('utf-8'))
self.assertTrue(
any('Japan' in item['text'] for item in data['results']),
f"Japan not found in: {data['results']}"
)
@ddt.data('eng', 'fren', 'GER')
def test_language_autocomplete_filters_correctly(self, term):
response = self.client.get(f'{self.language_url}?q={term}')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertTrue(any(term.lower() in item['text'].lower() for item in data['results']))
def test_language_autocomplete_returns_empty_on_no_match(self):
response = self.client.get(f'{self.language_url}?q=not-a-lang')
self.assertEqual(json.loads(response.content)['results'], [])
@ddt.data('United', 'Kingdom', 'Pakistan')
def test_country_autocomplete_filters_correctly(self, term):
response = self.client.get(f'{self.country_url}?q={term}')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertTrue(any(term.lower() in item['text'].lower() for item in data['results']))
def test_country_autocomplete_returns_empty_on_gibberish(self):
response = self.client.get(f'{self.country_url}?q=asdfghjkl')
self.assertEqual(json.loads(response.content)['results'], [])
def test_admin_inline_autocomplete_urls_render(self):
admin = UserFactory(is_staff=True, is_superuser=True)
admin.set_password('test')
admin.save()
user = UserFactory()
user.set_password('test')
user.save()
self.client.login(username=admin.username, password='test') # re-login as admin
response = self.client.get(reverse('admin:auth_user_change', args=[user.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.language_url)
self.assertContains(response, self.country_url)
def test_language_autocomplete_blocks_non_staff(self):
self.client.logout()
self.client.login(username=self.non_staff_user.username, password='test')
response = self.client.get(f'{self.language_url}?q=english')
data = json.loads(response.content)
self.assertEqual(data['results'], [])
def test_country_autocomplete_blocks_non_staff(self):
self.client.logout()
self.client.login(username=self.non_staff_user.username, password='test')
response = self.client.get(f'{self.country_url}?q=pakistan')
data = json.loads(response.content)
self.assertEqual(data['results'], [])
def test_language_autocomplete_blocks_anonymous_user(self):
"""Ensure anonymous user gets blocked or redirected."""
self.client.logout()
response = self.client.get(f'{self.language_url}?q=English')
self.assertIn(response.status_code, [302, 403])
def test_country_autocomplete_blocks_anonymous_user(self):
"""Ensure anonymous user gets blocked or redirected."""
self.client.logout()
response = self.client.get(f'{self.country_url}?q=Pakistan')
self.assertIn(response.status_code, [302, 403])
def test_language_autocomplete_status_for_non_staff(self):
self.client.logout()
self.client.login(username=self.non_staff_user.username, password='test')
response = self.client.get(f'{self.language_url}?q=English')
self.assertEqual(response.status_code, 200) # still 200, but empty results expected
self.assertEqual(json.loads(response.content)['results'], [])
def test_unknown_autocomplete_path_404s(self):
logged_in = self.client.login(username=self.staff_user.username, password='test')
assert logged_in, "Login failed — test user not authenticated"
response = self.client.get('/admin/myapp/mymodel/fake-autocomplete/')
self.assertEqual(response.status_code, 404)

View File

@@ -3042,6 +3042,9 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.sites',
'dal',
'dal_select2',
# Tweaked version of django.contrib.staticfiles
'openedx.core.djangoapps.staticfiles.apps.EdxPlatformStaticFilesConfig',

View File

@@ -1,8 +1,6 @@
"""
Django admin page for Site Configuration models
"""
from django.contrib import admin
from .models import SiteConfiguration, SiteConfigurationHistory

View File

@@ -170,6 +170,7 @@ django==4.2.21
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
# django-appconf
# django-autocomplete-light
# django-celery-results
# django-classy-tags
# django-config-models
@@ -239,6 +240,8 @@ django==4.2.21
# xss-utils
django-appconf==1.1.0
# via django-statici18n
django-autocomplete-light==3.12.1
# via -r requirements/edx/kernel.in
django-cache-memoize==0.2.1
# via edx-enterprise
django-celery-results==2.6.0

View File

@@ -340,6 +340,7 @@ django==4.2.21
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# django-appconf
# django-autocomplete-light
# django-celery-results
# django-classy-tags
# django-config-models
@@ -415,6 +416,10 @@ django-appconf==1.1.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# django-statici18n
django-autocomplete-light==3.12.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
django-cache-memoize==0.2.1
# via
# -r requirements/edx/doc.txt

View File

@@ -226,6 +226,7 @@ django==4.2.21
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
# django-appconf
# django-autocomplete-light
# django-celery-results
# django-classy-tags
# django-config-models
@@ -297,6 +298,8 @@ django-appconf==1.1.0
# via
# -r requirements/edx/base.txt
# django-statici18n
django-autocomplete-light==3.12.1
# via -r requirements/edx/base.txt
django-cache-memoize==0.2.1
# via
# -r requirements/edx/base.txt

View File

@@ -33,6 +33,7 @@ codejail-includes # CodeJail manages execution of untrusted co
cryptography # Implementations of assorted cryptography algorithms
defusedxml
Django # Web application framework
django-autocomplete-light # Enhances Django admin with single-select autocomplete dropdowns for a better user experience.
django-celery-results # Only used for the CacheBackend for celery results
django-config-models # Configuration models for Django allowing config management with auditing
django-cors-headers # Used to allow to configure CORS headers for cross-domain requests

View File

@@ -256,6 +256,7 @@ django==4.2.21
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
# django-appconf
# django-autocomplete-light
# django-celery-results
# django-classy-tags
# django-config-models
@@ -327,6 +328,8 @@ django-appconf==1.1.0
# via
# -r requirements/edx/base.txt
# django-statici18n
django-autocomplete-light==3.12.1
# via -r requirements/edx/base.txt
django-cache-memoize==0.2.1
# via
# -r requirements/edx/base.txt