Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 91 additions & 1 deletion common/djangoapps/student/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -11,12 +14,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
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
Expand Down Expand Up @@ -45,6 +50,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

Expand Down Expand Up @@ -309,9 +315,81 @@ 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):
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')

Expand Down Expand Up @@ -359,6 +437,18 @@ 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'
),
path('country-autocomplete/', CountryAutocomplete.as_view(), name='country-autocomplete'),
]
return custom_urls + urls


@admin.register(UserAttribute)
class UserAttributeAdmin(admin.ModelAdmin):
Expand Down
4 changes: 4 additions & 0 deletions common/djangoapps/student/constants.py
Original file line number Diff line number Diff line change
@@ -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')})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please explain why the condition for alpha_2 was added in this line or what is meant by alpha_2?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This checks if the language has a 2 letter language code e.g 'en' for english. If so, only then is it kept as I was maintaining uniformity and that is the usual standard.

165 changes: 164 additions & 1 deletion common/djangoapps/student/tests/test_admin_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@


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
Expand All @@ -24,7 +26,7 @@
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
Expand Down Expand Up @@ -532,3 +534,164 @@ 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'], [])

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)
3 changes: 3 additions & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Expand Down
2 changes: 0 additions & 2 deletions openedx/core/djangoapps/site_configuration/admin.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
"""
Django admin page for Site Configuration models
"""


from django.contrib import admin

from .models import SiteConfiguration, SiteConfigurationHistory
Expand Down
3 changes: 3 additions & 0 deletions requirements/edx/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions requirements/edx/development.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions requirements/edx/doc.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading