Merge pull request #36609 from musanaeem/musa/admin-revamp-dropdowns
Single Select Autocomplete Added to Student Admin
This commit is contained in:
@@ -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):
|
||||
|
||||
4
common/djangoapps/student/constants.py
Normal file
4
common/djangoapps/student/constants.py
Normal 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')})
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""
|
||||
Django admin page for Site Configuration models
|
||||
"""
|
||||
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import SiteConfiguration, SiteConfigurationHistory
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user