Skip to content

Commit 3d664b0

Browse files
authored
Merge pull request #36609 from musanaeem/musa/admin-revamp-dropdowns
Single Select Autocomplete Added to Student Admin
2 parents a01f4b1 + 30feea5 commit 3d664b0

File tree

10 files changed

+277
-4
lines changed

10 files changed

+277
-4
lines changed

common/djangoapps/student/admin.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33

44
from functools import wraps
5+
from dal_select2.views import Select2ListView
6+
from dal_select2.widgets import ListSelect2
7+
from django_countries import countries
58

69
from config_models.admin import ConfigurationModelAdmin
710
from django import forms
@@ -11,12 +14,14 @@
1114
from django.contrib.admin.utils import unquote
1215
from django.contrib.auth import get_user_model
1316
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
17+
from django.contrib.auth.decorators import login_required
1418
from django.contrib.auth.forms import ReadOnlyPasswordHashField
1519
from django.contrib.auth.forms import UserChangeForm as BaseUserChangeForm
1620
from django.db import models, router, transaction
1721
from django.http import HttpResponseRedirect
1822
from django.http.request import QueryDict
19-
from django.urls import reverse
23+
from django.urls import reverse, path
24+
from django.utils.decorators import method_decorator
2025
from django.utils.translation import ngettext
2126
from django.utils.translation import gettext_lazy as _
2227
from opaque_keys import InvalidKeyError
@@ -45,6 +50,7 @@
4550
UserProfile,
4651
UserTestGroup
4752
)
53+
from common.djangoapps.student.constants import LANGUAGE_CHOICES
4854
from common.djangoapps.student.roles import REGISTERED_ACCESS_ROLES
4955
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
5056

@@ -309,9 +315,81 @@ def get_queryset(self, request):
309315
return super().get_queryset(request).select_related('user') # lint-amnesty, pylint: disable=no-member, super-with-arguments
310316

311317

318+
@method_decorator(login_required, name='dispatch')
319+
class LanguageAutocomplete(Select2ListView):
320+
def get_list(self):
321+
if not self.request.user.is_staff:
322+
return []
323+
return [lang for lang in LANGUAGE_CHOICES if self.q.lower() in lang.lower()]
324+
325+
326+
@method_decorator(login_required, name='dispatch')
327+
class CountryAutocomplete(Select2ListView):
328+
"""
329+
Autocomplete view for selecting countries using Select2.
330+
331+
Only accessible to authenticated staff users. Filters the list of countries
332+
based on the user input (query string) and returns matching results.
333+
"""
334+
335+
def get_list(self):
336+
"""
337+
Returns a filtered list of country tuples (code, name) based on the query.
338+
"""
339+
if not self.request.user.is_staff:
340+
return []
341+
results = []
342+
for code, name in countries:
343+
if self.q.lower() in name.lower():
344+
results.append((code, name))
345+
return results
346+
347+
def get_result_label(self, item):
348+
""" What the user sees in the dropdown """
349+
return dict(countries).get(item, item)
350+
351+
def get_result_value(self, item):
352+
""" What gets sent back on selection (the code) """
353+
return item
354+
355+
356+
class UserProfileInlineForm(forms.ModelForm):
357+
"""
358+
A custom form for editing the UserProfile model within the admin inline.
359+
"""
360+
language = forms.CharField(
361+
required=False,
362+
widget=ListSelect2(url='admin:language-autocomplete') # pylint: disable=no-member
363+
)
364+
country = forms.CharField(
365+
required=False,
366+
widget=ListSelect2(url='admin:country-autocomplete') # pylint: disable=no-member
367+
)
368+
369+
class Meta:
370+
model = UserProfile
371+
fields = '__all__'
372+
373+
def __init__(self, *args, **kwargs):
374+
super().__init__(*args, **kwargs)
375+
376+
if self.instance and self.instance.pk:
377+
if self.instance.country:
378+
code = self.instance.country
379+
name = countries.name(code) if code in countries else code
380+
self.fields['country'].widget.choices = [(code, name)]
381+
self.initial['country'] = code
382+
383+
if self.instance.language:
384+
language = self.instance.language
385+
self.fields['language'].initial = language
386+
self.fields['language'].widget.choices = [(language, language)]
387+
388+
312389
class UserProfileInline(admin.StackedInline):
313390
""" Inline admin interface for UserProfile model. """
314391
model = UserProfile
392+
form = UserProfileInlineForm
315393
can_delete = False
316394
verbose_name_plural = _('User profile')
317395

@@ -359,6 +437,18 @@ def get_readonly_fields(self, request, obj=None):
359437
return django_readonly + ('username',)
360438
return django_readonly
361439

440+
def get_urls(self):
441+
urls = super().get_urls()
442+
custom_urls = [
443+
path(
444+
'language-autocomplete/',
445+
LanguageAutocomplete.as_view(),
446+
name='language-autocomplete'
447+
),
448+
path('country-autocomplete/', CountryAutocomplete.as_view(), name='country-autocomplete'),
449+
]
450+
return custom_urls + urls
451+
362452

363453
@admin.register(UserAttribute)
364454
class UserAttributeAdmin(admin.ModelAdmin):
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""# Generate a sorted list of unique language names from pycountry """
2+
import pycountry
3+
4+
LANGUAGE_CHOICES = sorted({lang.name for lang in pycountry.languages if hasattr(lang, 'alpha_2')})

common/djangoapps/student/tests/test_admin_views.py

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44

55

66
import datetime
7+
import json
78
from unittest.mock import Mock
89

910
import ddt
1011
import pytest
12+
1113
from django.contrib.admin.sites import AdminSite
1214
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
1315
from django.forms import ValidationError
@@ -24,7 +26,7 @@
2426
UserAdmin
2527
)
2628
from common.djangoapps.student.models import AllowedAuthUser, CourseEnrollment, LoginFailures
27-
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
29+
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory, UserProfileFactory
2830
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
2931
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
3032
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
@@ -532,3 +534,164 @@ def test_form_update(self):
532534
db_allowed_auth_user = AllowedAuthUser.objects.all().first()
533535
assert AllowedAuthUser.objects.all().count() == 1
534536
assert db_allowed_auth_user.email == self.other_valid_email
537+
538+
539+
@ddt.ddt
540+
class TestUserProfileAutocompleteAdmin(TestCase):
541+
"""Tests for language and country autocomplete in UserProfile inline form via Django admin."""
542+
543+
def setUp(self):
544+
super().setUp()
545+
self.staff_user = UserFactory(is_staff=True)
546+
self.staff_user.set_password('test')
547+
self.staff_user.save()
548+
549+
self.non_staff_user = UserFactory(is_staff=False)
550+
self.non_staff_user.set_password('test')
551+
self.non_staff_user.save()
552+
553+
self.client.login(username=self.staff_user.username, password='test')
554+
555+
self.language_url = reverse('admin:language-autocomplete')
556+
self.country_url = reverse('admin:country-autocomplete')
557+
558+
user1 = UserFactory()
559+
user1.set_password('test')
560+
user1.save()
561+
UserProfileFactory(user=user1, language='English', country='PK')
562+
563+
user2 = UserFactory()
564+
user2.set_password('test')
565+
user2.save()
566+
UserProfileFactory(user=user2, language='French', country='GB')
567+
568+
user3 = UserFactory()
569+
user3.set_password('test')
570+
user3.save()
571+
UserProfileFactory(user=user3, language='German', country='US')
572+
573+
def test_language_autocomplete_returns_expected_result(self):
574+
"""Verify language autocomplete returns expected filtered results."""
575+
profile = UserProfileFactory(user=self.staff_user, language='Esperanto')
576+
577+
response = self.client.get(self.language_url)
578+
self.assertEqual(response.status_code, 200)
579+
580+
data = json.loads(response.content.decode('utf-8'))
581+
self.assertTrue(
582+
any('Esperanto' in item['text'] for item in data['results']),
583+
f"Esperanto not found in: {data['results']}"
584+
)
585+
586+
profile.language = 'French'
587+
profile.save()
588+
589+
response = self.client.get(f'{self.language_url}?q=Fren')
590+
self.assertEqual(response.status_code, 200)
591+
592+
data = json.loads(response.content.decode('utf-8'))
593+
self.assertTrue(
594+
any('French' in item['text'] for item in data['results']),
595+
f"French not found in: {data['results']}"
596+
)
597+
598+
def test_country_autocomplete_returns_expected_result(self):
599+
"""Verify country autocomplete returns expected filtered results."""
600+
profile = UserProfileFactory(user=self.staff_user, country='SE')
601+
602+
response = self.client.get(self.country_url)
603+
self.assertEqual(response.status_code, 200)
604+
data = json.loads(response.content.decode('utf-8'))
605+
self.assertTrue(
606+
any('Sweden' in item['text'] for item in data['results']),
607+
f"Sweden not found in: {data['results']}"
608+
)
609+
610+
profile.country = 'JP'
611+
profile.save()
612+
613+
response = self.client.get(f'{self.country_url}?q=Japan')
614+
self.assertEqual(response.status_code, 200)
615+
616+
data = json.loads(response.content.decode('utf-8'))
617+
self.assertTrue(
618+
any('Japan' in item['text'] for item in data['results']),
619+
f"Japan not found in: {data['results']}"
620+
)
621+
622+
@ddt.data('eng', 'fren', 'GER')
623+
def test_language_autocomplete_filters_correctly(self, term):
624+
response = self.client.get(f'{self.language_url}?q={term}')
625+
self.assertEqual(response.status_code, 200)
626+
data = json.loads(response.content)
627+
self.assertTrue(any(term.lower() in item['text'].lower() for item in data['results']))
628+
629+
def test_language_autocomplete_returns_empty_on_no_match(self):
630+
response = self.client.get(f'{self.language_url}?q=not-a-lang')
631+
self.assertEqual(json.loads(response.content)['results'], [])
632+
633+
@ddt.data('United', 'Kingdom', 'Pakistan')
634+
def test_country_autocomplete_filters_correctly(self, term):
635+
response = self.client.get(f'{self.country_url}?q={term}')
636+
self.assertEqual(response.status_code, 200)
637+
data = json.loads(response.content)
638+
self.assertTrue(any(term.lower() in item['text'].lower() for item in data['results']))
639+
640+
def test_country_autocomplete_returns_empty_on_gibberish(self):
641+
response = self.client.get(f'{self.country_url}?q=asdfghjkl')
642+
self.assertEqual(json.loads(response.content)['results'], [])
643+
644+
def test_admin_inline_autocomplete_urls_render(self):
645+
admin = UserFactory(is_staff=True, is_superuser=True)
646+
admin.set_password('test')
647+
admin.save()
648+
649+
user = UserFactory()
650+
user.set_password('test')
651+
user.save()
652+
self.client.login(username=admin.username, password='test') # re-login as admin
653+
654+
response = self.client.get(reverse('admin:auth_user_change', args=[user.id]))
655+
self.assertEqual(response.status_code, 200)
656+
self.assertContains(response, self.language_url)
657+
self.assertContains(response, self.country_url)
658+
659+
def test_language_autocomplete_blocks_non_staff(self):
660+
self.client.logout()
661+
self.client.login(username=self.non_staff_user.username, password='test')
662+
response = self.client.get(f'{self.language_url}?q=english')
663+
data = json.loads(response.content)
664+
self.assertEqual(data['results'], [])
665+
666+
def test_country_autocomplete_blocks_non_staff(self):
667+
self.client.logout()
668+
self.client.login(username=self.non_staff_user.username, password='test')
669+
response = self.client.get(f'{self.country_url}?q=pakistan')
670+
data = json.loads(response.content)
671+
self.assertEqual(data['results'], [])
672+
673+
def test_language_autocomplete_blocks_anonymous_user(self):
674+
"""Ensure anonymous user gets blocked or redirected."""
675+
self.client.logout()
676+
response = self.client.get(f'{self.language_url}?q=English')
677+
self.assertIn(response.status_code, [302, 403])
678+
679+
def test_country_autocomplete_blocks_anonymous_user(self):
680+
"""Ensure anonymous user gets blocked or redirected."""
681+
self.client.logout()
682+
response = self.client.get(f'{self.country_url}?q=Pakistan')
683+
self.assertIn(response.status_code, [302, 403])
684+
685+
def test_language_autocomplete_status_for_non_staff(self):
686+
self.client.logout()
687+
self.client.login(username=self.non_staff_user.username, password='test')
688+
response = self.client.get(f'{self.language_url}?q=English')
689+
self.assertEqual(response.status_code, 200) # still 200, but empty results expected
690+
self.assertEqual(json.loads(response.content)['results'], [])
691+
692+
def test_unknown_autocomplete_path_404s(self):
693+
logged_in = self.client.login(username=self.staff_user.username, password='test')
694+
assert logged_in, "Login failed — test user not authenticated"
695+
696+
response = self.client.get('/admin/myapp/mymodel/fake-autocomplete/')
697+
self.assertEqual(response.status_code, 404)

lms/envs/common.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3042,6 +3042,9 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
30423042
'django.contrib.sessions',
30433043
'django.contrib.sites',
30443044

3045+
'dal',
3046+
'dal_select2',
3047+
30453048
# Tweaked version of django.contrib.staticfiles
30463049
'openedx.core.djangoapps.staticfiles.apps.EdxPlatformStaticFilesConfig',
30473050

openedx/core/djangoapps/site_configuration/admin.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
"""
22
Django admin page for Site Configuration models
33
"""
4-
5-
64
from django.contrib import admin
75

86
from .models import SiteConfiguration, SiteConfigurationHistory

requirements/edx/base.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ django==4.2.21
170170
# -c requirements/edx/../constraints.txt
171171
# -r requirements/edx/kernel.in
172172
# django-appconf
173+
# django-autocomplete-light
173174
# django-celery-results
174175
# django-classy-tags
175176
# django-config-models
@@ -239,6 +240,8 @@ django==4.2.21
239240
# xss-utils
240241
django-appconf==1.1.0
241242
# via django-statici18n
243+
django-autocomplete-light==3.12.1
244+
# via -r requirements/edx/kernel.in
242245
django-cache-memoize==0.2.1
243246
# via edx-enterprise
244247
django-celery-results==2.6.0

requirements/edx/development.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ django==4.2.21
340340
# -r requirements/edx/doc.txt
341341
# -r requirements/edx/testing.txt
342342
# django-appconf
343+
# django-autocomplete-light
343344
# django-celery-results
344345
# django-classy-tags
345346
# django-config-models
@@ -415,6 +416,10 @@ django-appconf==1.1.0
415416
# -r requirements/edx/doc.txt
416417
# -r requirements/edx/testing.txt
417418
# django-statici18n
419+
django-autocomplete-light==3.12.1
420+
# via
421+
# -r requirements/edx/doc.txt
422+
# -r requirements/edx/testing.txt
418423
django-cache-memoize==0.2.1
419424
# via
420425
# -r requirements/edx/doc.txt

requirements/edx/doc.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ django==4.2.21
226226
# -c requirements/edx/../constraints.txt
227227
# -r requirements/edx/base.txt
228228
# django-appconf
229+
# django-autocomplete-light
229230
# django-celery-results
230231
# django-classy-tags
231232
# django-config-models
@@ -297,6 +298,8 @@ django-appconf==1.1.0
297298
# via
298299
# -r requirements/edx/base.txt
299300
# django-statici18n
301+
django-autocomplete-light==3.12.1
302+
# via -r requirements/edx/base.txt
300303
django-cache-memoize==0.2.1
301304
# via
302305
# -r requirements/edx/base.txt

0 commit comments

Comments
 (0)