From 2522bc6ff9bc87786acfb37a0bb5c35644109ffd Mon Sep 17 00:00:00 2001 From: musanaeem <110596170+musanaeem@users.noreply.github.com> Date: Wed, 23 Apr 2025 21:03:24 +0500 Subject: [PATCH 01/13] feat: Implemented single and multiselect autocomplete --- common/djangoapps/student/admin.py | 31 +++++++- common/djangoapps/student/constants.py | 3 + lms/envs/common.py | 3 + .../djangoapps/site_configuration/admin.py | 77 ++++++++++++++++++- .../site_configuration/constants.py | 6 ++ 5 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 common/djangoapps/student/constants.py create mode 100644 openedx/core/djangoapps/site_configuration/constants.py diff --git a/common/djangoapps/student/admin.py b/common/djangoapps/student/admin.py index 1c73937c1571..35163a2dd4e7 100644 --- a/common/djangoapps/student/admin.py +++ b/common/djangoapps/student/admin.py @@ -2,6 +2,7 @@ from functools import wraps +from dal import autocomplete from config_models.admin import ConfigurationModelAdmin from django import forms @@ -16,7 +17,7 @@ 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.translation import ngettext from django.utils.translation import gettext_lazy as _ from opaque_keys import InvalidKeyError @@ -45,6 +46,7 @@ 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 +311,25 @@ def get_queryset(self, request): return super().get_queryset(request).select_related('user') # lint-amnesty, pylint: disable=no-member, super-with-arguments +class LanguageAutocomplete(autocomplete.Select2ListView): + def get_list(self): + return [lang for lang in LANGUAGE_CHOICES if self.q.lower() in lang.lower()] + +class UserProfileInlineForm(forms.ModelForm): + language = forms.CharField( + required=False, + widget=autocomplete.ListSelect2(url='admin:language-autocomplete') + ) + + class Meta: + model = UserProfile + fields = '__all__' + + class UserProfileInline(admin.StackedInline): """ Inline admin interface for UserProfile model. """ model = UserProfile + form = UserProfileInlineForm can_delete = False verbose_name_plural = _('User profile') @@ -359,6 +377,17 @@ def get_readonly_fields(self, request, obj=None): 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' + ), + ] + 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 000000000000..9ed0da02a82c --- /dev/null +++ b/common/djangoapps/student/constants.py @@ -0,0 +1,3 @@ +import pycountry + +LANGUAGE_CHOICES = sorted({lang.name for lang in pycountry.languages if hasattr(lang, 'alpha_2')}) diff --git a/lms/envs/common.py b/lms/envs/common.py index 68dda72b690e..3ace69ab0632 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3042,6 +3042,9 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring '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 64140699429e..d6584eb6e729 100644 --- a/openedx/core/djangoapps/site_configuration/admin.py +++ b/openedx/core/djangoapps/site_configuration/admin.py @@ -1,17 +1,81 @@ """ Django admin page for Site Configuration models """ +from dal import autocomplete - +from django import forms +from django.urls import path +from django.utils.translation import gettext_lazy as _ from django.contrib import admin +from .constants import FEATURE_FLAGS from .models import SiteConfiguration, SiteConfigurationHistory +class FeatureFlagAutocomplete(autocomplete.Select2ListView): + def get_list(self): + return list(FEATURE_FLAGS.keys()) + + def get_result_label(self, item): + return item + + def get_result_value(self, item): + return item + +class SiteConfigurationForm(forms.ModelForm): + feature_flags = forms.Field( + required=False, + widget=autocomplete.Select2Multiple( + url='admin:feature-flag-autocomplete', + attrs={ + 'multiple': 'multiple', + 'data-tags': 'true', + 'data-placeholder': 'Select features' + } + ), + label="Enabled Features", + ) + + class Meta: + model = SiteConfiguration + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['site_values'].widget = forms.HiddenInput() + current_values = self.instance.site_values or {} + selected_labels = [] + for label, mapping in FEATURE_FLAGS.items(): + if all(current_values.get(k) == v for k, v in mapping.items()): + selected_labels.append(label) + + self.fields['feature_flags'].initial = selected_labels + self.fields['feature_flags'].widget.choices = [(v, v) for v in selected_labels] + + + def clean(self): + cleaned = super().clean() + + selected_flags = self.data.getlist('feature_flags') + if not isinstance(selected_flags, list): + selected_flags = [selected_flags] if selected_flags else [] + + site_values = {} + for label in selected_flags: + site_values.update(FEATURE_FLAGS.get(label, {})) + + cleaned['feature_flags'] = selected_flags + cleaned['site_values'] = site_values + # self.selected_flags = selected_flags + # self.site_values = site_values + + return cleaned class SiteConfigurationAdmin(admin.ModelAdmin): """ Admin interface for the SiteConfiguration object. """ + form = SiteConfigurationForm list_display = ('site', 'enabled', 'site_values') search_fields = ('site__domain', 'site_values') @@ -21,6 +85,17 @@ class Meta: """ model = SiteConfiguration + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path( + 'feature-flag-autocomplete/', + FeatureFlagAutocomplete.as_view(), + name='feature-flag-autocomplete' + ), + ] + return custom_urls + urls + admin.site.register(SiteConfiguration, SiteConfigurationAdmin) diff --git a/openedx/core/djangoapps/site_configuration/constants.py b/openedx/core/djangoapps/site_configuration/constants.py new file mode 100644 index 000000000000..ea743121144a --- /dev/null +++ b/openedx/core/djangoapps/site_configuration/constants.py @@ -0,0 +1,6 @@ +# TODO: Dummy Tags to be replaced by real values +FEATURE_FLAGS = { + 'Forum Notifications': {'enable_forum_notifications': True}, + 'Live Chat': {'enable_live_chat': True}, + 'Dark Mode': {'enable_dark_mode': True}, +} From 54509898b5c2346d4490336579a983f2f71b1a47 Mon Sep 17 00:00:00 2001 From: musanaeem <110596170+musanaeem@users.noreply.github.com> Date: Fri, 25 Apr 2025 00:29:36 +0500 Subject: [PATCH 02/13] feat: added autocomplete to country field --- common/djangoapps/student/admin.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/common/djangoapps/student/admin.py b/common/djangoapps/student/admin.py index 35163a2dd4e7..3792107c145b 100644 --- a/common/djangoapps/student/admin.py +++ b/common/djangoapps/student/admin.py @@ -3,6 +3,7 @@ from functools import wraps from dal import autocomplete +from django_countries import countries from config_models.admin import ConfigurationModelAdmin from django import forms @@ -315,16 +316,41 @@ class LanguageAutocomplete(autocomplete.Select2ListView): def get_list(self): return [lang for lang in LANGUAGE_CHOICES if self.q.lower() in lang.lower()] +class CountryAutocomplete(autocomplete.Select2ListView): + def get_list(self): + return [ + name for code, name in countries if self.q.lower() in name.lower() + ] + class UserProfileInlineForm(forms.ModelForm): language = forms.CharField( required=False, widget=autocomplete.ListSelect2(url='admin:language-autocomplete') ) + country = forms.CharField( + required=False, + widget=autocomplete.ListSelect2(url='admin:country-autocomplete') + ) 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. """ @@ -385,6 +411,7 @@ def get_urls(self): LanguageAutocomplete.as_view(), name='language-autocomplete' ), + path('country-autocomplete/', CountryAutocomplete.as_view(), name='country-autocomplete'), ] return custom_urls + urls From 13169fba864ea274cd609ec87a98a7da07be7866 Mon Sep 17 00:00:00 2001 From: musanaeem <110596170+musanaeem@users.noreply.github.com> Date: Sun, 27 Apr 2025 15:43:23 +0500 Subject: [PATCH 03/13] fix: updated json conversion to add custom values --- openedx/core/djangoapps/site_configuration/admin.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/openedx/core/djangoapps/site_configuration/admin.py b/openedx/core/djangoapps/site_configuration/admin.py index d6584eb6e729..e6a6c0e8057c 100644 --- a/openedx/core/djangoapps/site_configuration/admin.py +++ b/openedx/core/djangoapps/site_configuration/admin.py @@ -2,6 +2,7 @@ Django admin page for Site Configuration models """ from dal import autocomplete +import json from django import forms from django.urls import path @@ -42,7 +43,6 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['site_values'].widget = forms.HiddenInput() current_values = self.instance.site_values or {} selected_labels = [] for label, mapping in FEATURE_FLAGS.items(): @@ -55,19 +55,23 @@ def __init__(self, *args, **kwargs): def clean(self): cleaned = super().clean() - + current_site_values = json.loads(self.data.get('site_values', {})) selected_flags = self.data.getlist('feature_flags') if not isinstance(selected_flags, list): selected_flags = [selected_flags] if selected_flags else [] + flag_keys = {key for group in FEATURE_FLAGS.values() for key in group} + site_values = {} for label in selected_flags: site_values.update(FEATURE_FLAGS.get(label, {})) + for key, value in current_site_values.items(): + if key not in flag_keys: + site_values[key] = value + cleaned['feature_flags'] = selected_flags cleaned['site_values'] = site_values - # self.selected_flags = selected_flags - # self.site_values = site_values return cleaned From d7eea9d37adc60124ba7fbfb334de276b7ac8f1c Mon Sep 17 00:00:00 2001 From: musanaeem <110596170+musanaeem@users.noreply.github.com> Date: Mon, 28 Apr 2025 02:49:54 +0500 Subject: [PATCH 04/13] fix: Remove complex change --- .../djangoapps/site_configuration/admin.py | 80 ------------------- .../site_configuration/constants.py | 6 -- 2 files changed, 86 deletions(-) delete mode 100644 openedx/core/djangoapps/site_configuration/constants.py diff --git a/openedx/core/djangoapps/site_configuration/admin.py b/openedx/core/djangoapps/site_configuration/admin.py index e6a6c0e8057c..2692070acb48 100644 --- a/openedx/core/djangoapps/site_configuration/admin.py +++ b/openedx/core/djangoapps/site_configuration/admin.py @@ -1,85 +1,16 @@ """ Django admin page for Site Configuration models """ -from dal import autocomplete -import json - -from django import forms -from django.urls import path from django.utils.translation import gettext_lazy as _ from django.contrib import admin -from .constants import FEATURE_FLAGS from .models import SiteConfiguration, SiteConfigurationHistory -class FeatureFlagAutocomplete(autocomplete.Select2ListView): - def get_list(self): - return list(FEATURE_FLAGS.keys()) - - def get_result_label(self, item): - return item - - def get_result_value(self, item): - return item - -class SiteConfigurationForm(forms.ModelForm): - feature_flags = forms.Field( - required=False, - widget=autocomplete.Select2Multiple( - url='admin:feature-flag-autocomplete', - attrs={ - 'multiple': 'multiple', - 'data-tags': 'true', - 'data-placeholder': 'Select features' - } - ), - label="Enabled Features", - ) - - class Meta: - model = SiteConfiguration - fields = '__all__' - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - current_values = self.instance.site_values or {} - selected_labels = [] - for label, mapping in FEATURE_FLAGS.items(): - if all(current_values.get(k) == v for k, v in mapping.items()): - selected_labels.append(label) - - self.fields['feature_flags'].initial = selected_labels - self.fields['feature_flags'].widget.choices = [(v, v) for v in selected_labels] - - - def clean(self): - cleaned = super().clean() - current_site_values = json.loads(self.data.get('site_values', {})) - selected_flags = self.data.getlist('feature_flags') - if not isinstance(selected_flags, list): - selected_flags = [selected_flags] if selected_flags else [] - - flag_keys = {key for group in FEATURE_FLAGS.values() for key in group} - - site_values = {} - for label in selected_flags: - site_values.update(FEATURE_FLAGS.get(label, {})) - - for key, value in current_site_values.items(): - if key not in flag_keys: - site_values[key] = value - - cleaned['feature_flags'] = selected_flags - cleaned['site_values'] = site_values - - return cleaned class SiteConfigurationAdmin(admin.ModelAdmin): """ Admin interface for the SiteConfiguration object. """ - form = SiteConfigurationForm list_display = ('site', 'enabled', 'site_values') search_fields = ('site__domain', 'site_values') @@ -89,17 +20,6 @@ class Meta: """ model = SiteConfiguration - def get_urls(self): - urls = super().get_urls() - custom_urls = [ - path( - 'feature-flag-autocomplete/', - FeatureFlagAutocomplete.as_view(), - name='feature-flag-autocomplete' - ), - ] - return custom_urls + urls - admin.site.register(SiteConfiguration, SiteConfigurationAdmin) diff --git a/openedx/core/djangoapps/site_configuration/constants.py b/openedx/core/djangoapps/site_configuration/constants.py deleted file mode 100644 index ea743121144a..000000000000 --- a/openedx/core/djangoapps/site_configuration/constants.py +++ /dev/null @@ -1,6 +0,0 @@ -# TODO: Dummy Tags to be replaced by real values -FEATURE_FLAGS = { - 'Forum Notifications': {'enable_forum_notifications': True}, - 'Live Chat': {'enable_live_chat': True}, - 'Dark Mode': {'enable_dark_mode': True}, -} From a58b650f4ff0704e11926397b8de9b2e35dfb2e6 Mon Sep 17 00:00:00 2001 From: hinakhadim Date: Mon, 28 Apr 2025 16:07:22 +0500 Subject: [PATCH 05/13] fix: add django-autocomplete-light package in kernel.in and make compile --- requirements/edx/base.txt | 3 +++ requirements/edx/development.txt | 5 +++++ requirements/edx/doc.txt | 3 +++ requirements/edx/kernel.in | 1 + requirements/edx/testing.txt | 3 +++ 5 files changed, 15 insertions(+) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 7d988ac942b5..f15f6fa6dbbe 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 @@ -238,6 +239,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 eb630929cf6f..25a3107f51fd 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 @@ -414,6 +415,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 dbc54dfc4c9f..93707495db74 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 @@ -296,6 +297,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 55fa2f29082d..caec5c8c04c9 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 022f7b61a421..e07489bab39c 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 @@ -326,6 +327,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 From 7c6b3ae92c543b36c6896aac33644797572e225d Mon Sep 17 00:00:00 2001 From: musanaeem <110596170+musanaeem@users.noreply.github.com> Date: Tue, 29 Apr 2025 04:35:49 +0500 Subject: [PATCH 06/13] fix: removed redacted import --- openedx/core/djangoapps/site_configuration/admin.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openedx/core/djangoapps/site_configuration/admin.py b/openedx/core/djangoapps/site_configuration/admin.py index 2692070acb48..851a70286afc 100644 --- a/openedx/core/djangoapps/site_configuration/admin.py +++ b/openedx/core/djangoapps/site_configuration/admin.py @@ -1,7 +1,6 @@ """ Django admin page for Site Configuration models """ -from django.utils.translation import gettext_lazy as _ from django.contrib import admin from .models import SiteConfiguration, SiteConfigurationHistory From 8eed852a2315827a1f53f8d4921a51955e7ae437 Mon Sep 17 00:00:00 2001 From: musanaeem <110596170+musanaeem@users.noreply.github.com> Date: Tue, 29 Apr 2025 05:03:41 +0500 Subject: [PATCH 07/13] fix: save code and not name --- common/djangoapps/student/admin.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/common/djangoapps/student/admin.py b/common/djangoapps/student/admin.py index 3792107c145b..7742f663255c 100644 --- a/common/djangoapps/student/admin.py +++ b/common/djangoapps/student/admin.py @@ -318,9 +318,19 @@ def get_list(self): class CountryAutocomplete(autocomplete.Select2ListView): def get_list(self): - return [ - name for code, name in countries if self.q.lower() in name.lower() - ] + 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): language = forms.CharField( From 10e05f306b114619d8bc5a9b1cc08d0d6552a335 Mon Sep 17 00:00:00 2001 From: musanaeem <110596170+musanaeem@users.noreply.github.com> Date: Tue, 6 May 2025 17:05:47 +0500 Subject: [PATCH 08/13] feat: added tests --- common/djangoapps/student/admin.py | 4 + .../student/tests/test_admin_views.py | 142 +++++++++++++++++- 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/common/djangoapps/student/admin.py b/common/djangoapps/student/admin.py index 7742f663255c..98baf6e558cc 100644 --- a/common/djangoapps/student/admin.py +++ b/common/djangoapps/student/admin.py @@ -314,10 +314,14 @@ def get_queryset(self, request): class LanguageAutocomplete(autocomplete.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()] class CountryAutocomplete(autocomplete.Select2ListView): def get_list(self): + if not self.request.user.is_staff: + return [] results = [] for code, name in countries: if self.q.lower() in name.lower(): diff --git a/common/djangoapps/student/tests/test_admin_views.py b/common/djangoapps/student/tests/test_admin_views.py index 2914bcd61c43..43a3d560d5fb 100644 --- a/common/djangoapps/student/tests/test_admin_views.py +++ b/common/djangoapps/student/tests/test_admin_views.py @@ -4,12 +4,15 @@ 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_countries.data import COUNTRIES as countries from django.forms import ValidationError from django.test import TestCase, override_settings from django.urls import reverse @@ -24,13 +27,14 @@ 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 from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order + class AdminCourseRolesPageTest(SharedModuleStoreTestCase): """Test the django admin course roles form saving data in db. """ @@ -532,3 +536,139 @@ def test_form_update(self): 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'], []) From 355eb1bc5526972e465c2692f2498bc33992f580 Mon Sep 17 00:00:00 2001 From: musanaeem <110596170+musanaeem@users.noreply.github.com> Date: Tue, 6 May 2025 21:30:34 +0500 Subject: [PATCH 09/13] feat: added more tests --- common/djangoapps/student/admin.py | 5 +++- .../student/tests/test_admin_views.py | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/common/djangoapps/student/admin.py b/common/djangoapps/student/admin.py index 98baf6e558cc..3c23528c872b 100644 --- a/common/djangoapps/student/admin.py +++ b/common/djangoapps/student/admin.py @@ -13,12 +13,14 @@ 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, 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 @@ -311,13 +313,14 @@ def get_search_results(self, request, queryset, search_term): def get_queryset(self, request): 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(autocomplete.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(autocomplete.Select2ListView): def get_list(self): if not self.request.user.is_staff: diff --git a/common/djangoapps/student/tests/test_admin_views.py b/common/djangoapps/student/tests/test_admin_views.py index 43a3d560d5fb..f116fdd5f85a 100644 --- a/common/djangoapps/student/tests/test_admin_views.py +++ b/common/djangoapps/student/tests/test_admin_views.py @@ -672,3 +672,29 @@ def test_country_autocomplete_blocks_non_staff(self): 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) From ec8c190ab0f18fc76456d94e7a12179572a24d2c Mon Sep 17 00:00:00 2001 From: awais qureshi Date: Tue, 3 Jun 2025 15:10:40 -0400 Subject: [PATCH 10/13] feat: implementing autocomplete for django admin pages. --- common/djangoapps/student/admin.py | 13 +++++++++++++ common/djangoapps/student/constants.py | 1 + common/djangoapps/student/tests/test_admin_views.py | 2 -- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/common/djangoapps/student/admin.py b/common/djangoapps/student/admin.py index 3c23528c872b..81ed113811db 100644 --- a/common/djangoapps/student/admin.py +++ b/common/djangoapps/student/admin.py @@ -322,7 +322,17 @@ def get_list(self): @method_decorator(login_required, name='dispatch') class CountryAutocomplete(autocomplete.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 = [] @@ -340,6 +350,9 @@ def get_result_value(self, item): return item class UserProfileInlineForm(forms.ModelForm): + """ + A custom form for editing the UserProfile model within the admin inline. + """ language = forms.CharField( required=False, widget=autocomplete.ListSelect2(url='admin:language-autocomplete') diff --git a/common/djangoapps/student/constants.py b/common/djangoapps/student/constants.py index 9ed0da02a82c..43607f137305 100644 --- a/common/djangoapps/student/constants.py +++ b/common/djangoapps/student/constants.py @@ -1,3 +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 f116fdd5f85a..00aab7c3f080 100644 --- a/common/djangoapps/student/tests/test_admin_views.py +++ b/common/djangoapps/student/tests/test_admin_views.py @@ -12,7 +12,6 @@ from django.contrib.admin.sites import AdminSite from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user -from django_countries.data import COUNTRIES as countries from django.forms import ValidationError from django.test import TestCase, override_settings from django.urls import reverse @@ -651,7 +650,6 @@ def test_admin_inline_autocomplete_urls_render(self): 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])) From 89509ecc53e5c58f1b4137ac336977182bc2b79a Mon Sep 17 00:00:00 2001 From: awais qureshi Date: Tue, 3 Jun 2025 15:26:49 -0400 Subject: [PATCH 11/13] feat: implementing autocomplete for django admin pages. --- common/djangoapps/student/admin.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/common/djangoapps/student/admin.py b/common/djangoapps/student/admin.py index 81ed113811db..bc1f1c0e27d9 100644 --- a/common/djangoapps/student/admin.py +++ b/common/djangoapps/student/admin.py @@ -314,14 +314,14 @@ def get_queryset(self, request): 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(autocomplete.Select2ListView): +class LanguageAutocomplete(autocomplete.Select2ListView): # pylint: disable=no-member 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(autocomplete.Select2ListView): +class CountryAutocomplete(autocomplete.Select2ListView): # pylint: disable=no-member """ Autocomplete view for selecting countries using Select2. @@ -355,11 +355,11 @@ class UserProfileInlineForm(forms.ModelForm): """ language = forms.CharField( required=False, - widget=autocomplete.ListSelect2(url='admin:language-autocomplete') + widget=autocomplete.ListSelect2(url='admin:language-autocomplete') # pylint: disable=no-member ) country = forms.CharField( required=False, - widget=autocomplete.ListSelect2(url='admin:country-autocomplete') + widget=autocomplete.ListSelect2(url='admin:country-autocomplete') # pylint: disable=no-member ) class Meta: From 77f081286f196d5d0ec56c250decb287ad84ea98 Mon Sep 17 00:00:00 2001 From: awais qureshi Date: Tue, 3 Jun 2025 15:35:02 -0400 Subject: [PATCH 12/13] feat: implementing autocomplete for django admin pages. --- common/djangoapps/student/admin.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/common/djangoapps/student/admin.py b/common/djangoapps/student/admin.py index bc1f1c0e27d9..127290243dca 100644 --- a/common/djangoapps/student/admin.py +++ b/common/djangoapps/student/admin.py @@ -2,7 +2,8 @@ from functools import wraps -from dal import autocomplete +from dal_select2.views import Select2ListView +from dal_select2.widgets import ListSelect2 from django_countries import countries from config_models.admin import ConfigurationModelAdmin @@ -314,14 +315,14 @@ def get_queryset(self, request): 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(autocomplete.Select2ListView): # pylint: disable=no-member +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(autocomplete.Select2ListView): # pylint: disable=no-member +class CountryAutocomplete(Select2ListView): """ Autocomplete view for selecting countries using Select2. @@ -355,11 +356,11 @@ class UserProfileInlineForm(forms.ModelForm): """ language = forms.CharField( required=False, - widget=autocomplete.ListSelect2(url='admin:language-autocomplete') # pylint: disable=no-member + widget=ListSelect2(url='admin:language-autocomplete') # pylint: disable=no-member ) country = forms.CharField( required=False, - widget=autocomplete.ListSelect2(url='admin:country-autocomplete') # pylint: disable=no-member + widget=ListSelect2(url='admin:country-autocomplete') # pylint: disable=no-member ) class Meta: From 30feea5bfdf311a0b6b2608933c6b50a323c6490 Mon Sep 17 00:00:00 2001 From: awais qureshi Date: Tue, 3 Jun 2025 15:45:38 -0400 Subject: [PATCH 13/13] feat: implementing autocomplete for django admin pages. --- common/djangoapps/student/admin.py | 3 +++ common/djangoapps/student/tests/test_admin_views.py | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/common/djangoapps/student/admin.py b/common/djangoapps/student/admin.py index 127290243dca..677b74b1bf94 100644 --- a/common/djangoapps/student/admin.py +++ b/common/djangoapps/student/admin.py @@ -314,6 +314,7 @@ def get_search_results(self, request, queryset, search_term): def get_queryset(self, request): 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): @@ -321,6 +322,7 @@ def get_list(self): return [] return [lang for lang in LANGUAGE_CHOICES if self.q.lower() in lang.lower()] + @method_decorator(login_required, name='dispatch') class CountryAutocomplete(Select2ListView): """ @@ -350,6 +352,7 @@ 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. diff --git a/common/djangoapps/student/tests/test_admin_views.py b/common/djangoapps/student/tests/test_admin_views.py index 00aab7c3f080..968ba330a99a 100644 --- a/common/djangoapps/student/tests/test_admin_views.py +++ b/common/djangoapps/student/tests/test_admin_views.py @@ -33,7 +33,6 @@ from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order - class AdminCourseRolesPageTest(SharedModuleStoreTestCase): """Test the django admin course roles form saving data in db. """