diff --git a/common/djangoapps/student/admin.py b/common/djangoapps/student/admin.py index 1c73937c15..677b74b1bf 100644 --- a/common/djangoapps/student/admin.py +++ b/common/djangoapps/student/admin.py @@ -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): diff --git a/common/djangoapps/student/constants.py b/common/djangoapps/student/constants.py new file mode 100644 index 0000000000..43607f1373 --- /dev/null +++ b/common/djangoapps/student/constants.py @@ -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')}) diff --git a/common/djangoapps/student/tests/test_admin_views.py b/common/djangoapps/student/tests/test_admin_views.py index 2914bcd61c..968ba330a9 100644 --- a/common/djangoapps/student/tests/test_admin_views.py +++ b/common/djangoapps/student/tests/test_admin_views.py @@ -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) diff --git a/lms/envs/common.py b/lms/envs/common.py index 68dda72b69..3ace69ab06 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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', diff --git a/openedx/core/djangoapps/site_configuration/admin.py b/openedx/core/djangoapps/site_configuration/admin.py index 6414069942..851a70286a 100644 --- a/openedx/core/djangoapps/site_configuration/admin.py +++ b/openedx/core/djangoapps/site_configuration/admin.py @@ -1,8 +1,6 @@ """ Django admin page for Site Configuration models """ - - from django.contrib import admin from .models import SiteConfiguration, SiteConfigurationHistory diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index dc254eeda1..852c618d11 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -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 diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index f2bc351e9f..345402efab 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -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 diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 107dafc09a..3b45fc0cdf 100644 --- a/requirements/edx/doc.txt +++ b/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 diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index 55fa2f2908..caec5c8c04 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -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 diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index efca14e92f..90175eb918 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -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