Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion .github/workflows/ruff.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
strategy:
max-parallel: 4
matrix:
python-version: ["3.11", "3.12", "3.13"]
python-version: ["3.11", "3.12", "3.13", "3.14"]

steps:
- name: Check out repository
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
strategy:
max-parallel: 4
matrix:
python-version: ["3.11", "3.12", "3.13"]
python-version: ["3.11", "3.12", "3.13", "3.14"]

steps:
- name: Check out repository
Expand Down
2 changes: 1 addition & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ pytest==8.4.2
pytest-cov==7.0.0
pytest-django==4.11.1
pytest-factoryboy==2.8.1
ruff==0.13.3
ruff==0.14.14
10 changes: 10 additions & 0 deletions src/dalf/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ def __init__(self, field, request, params, model, model_admin, field_path):
selected_value = originial_params.get(self.lookup_kwarg, [])
self.selected_value = selected_value[0] if selected_value else None

self.selected_text = None
if self.selected_value:
try:
related_model = field.remote_field.model
obj = related_model.objects.get(pk=self.selected_value)
self.selected_text = str(obj)
except (related_model.DoesNotExist, ValueError):
self.selected_value = None

Copilot AI Jan 24, 2026

Copy link

Choose a reason for hiding this comment

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

On DoesNotExist you set self.selected_value = None, but the query parameter is still present so the changelist remains filtered while the UI shows “All” and the clear button won’t appear (user can get stuck with an un-clearable filter). Instead, keep selected_value and set selected_text to a safe fallback (e.g., the raw ID or a localized “(deleted)” label) so Select2 can render a selected option and allow clearing.

Suggested change
self.selected_value = None
# Keep selected_value so the filter state matches the query parameter,
# but use a safe fallback label so the UI can render and clear it.
self.selected_text = str(self.selected_value)

Copilot uses AI. Check for mistakes.

Copilot AI Jan 24, 2026

Copy link

Choose a reason for hiding this comment

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

selected_text is derived via a direct related_model.objects.get(pk=...) without going through the admin/autocomplete permission/queryset logic. This can expose the string representation of related objects a user may not have permission to view (and may bypass any admin/queryset restrictions that autocomplete would enforce). Consider resolving the related object via the related model’s registered ModelAdmin (e.g., model_admin.admin_site._registry[related_model]) and its has_view_permission/get_queryset(request) (or fall back to leaving selected_text empty when not permitted).

Suggested change
try:
related_model = field.remote_field.model
obj = related_model.objects.get(pk=self.selected_value)
self.selected_text = str(obj)
except (related_model.DoesNotExist, ValueError):
self.selected_value = None
related_model = field.remote_field.model
# Resolve via the related model's ModelAdmin to respect admin permissions
related_admin = model_admin.admin_site._registry.get(related_model)
if related_admin is not None:
qs = related_admin.get_queryset(request)
try:
obj = qs.get(pk=self.selected_value)
except (related_model.DoesNotExist, ValueError):
# Keep behavior consistent: clear value if object does not exist
self.selected_value = None
else:
if related_admin.has_view_permission(request, obj):
self.selected_text = str(obj)
else:
# User is not allowed to view this object; do not expose its string
self.selected_text = None
# If there is no registered ModelAdmin for the related model,
# we do not attempt a direct objects.get(...) to avoid bypassing permissions.

Copilot uses AI. Check for mistakes.

def field_choices(self, _field, _request, _model_admin):
return []

Expand All @@ -97,4 +106,5 @@ def choices(self, changelist):
yield {
**self.custom_template_params,
'selected_value': self.selected_value,
'selected_text': self.selected_text,
}
27 changes: 5 additions & 22 deletions src/dalf/static/admin/js/django_admin_list_filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@

$.fn.djangoAdminListFilterSelect2 = function() {
$.each(this, function(i, element) {
const ajaxURL = $(element).data("ajax--url");
const appLabel = element.dataset.appLabel;
const modelName = element.dataset.modelName;
const fieldName = element.dataset.fieldName;
const lookupKwarg = element.dataset.lookupKwarg;
const selectedValue = $(element).prev('.djal-selected-value').val();
const selectedValue = $(element).prevAll('.djal-selected-value').first().val();
const selectedText = $(element).prevAll('.djal-selected-text').first().val();

$(element).select2({
ajax: {
Expand All @@ -35,26 +35,9 @@
window.location.href = navURL.href;
});

if (selectedValue){
$.ajax({
url: ajaxURL,
dataType: "json",
data: {
term: "",
app_label: appLabel,
model_name: modelName,
field_name: fieldName
},
success: function(data){
if (data.results.length > 0) {
const selectedItem = data.results.find(item => item.id === selectedValue);
if (selectedItem) {
const selectedOption = new Option(selectedItem.text, selectedItem.id, true, true);
$(element).append(selectedOption).trigger("change");
}
};
}
});
if (selectedValue && selectedText) {
const selectedOption = new Option(selectedText, selectedValue, true, true);
$(element).append(selectedOption).trigger("change");
}

});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<li>
{% with params=choices|last %}
<input class="djal-selected-value" type="hidden" value="{{ params.selected_value|default_if_none:'' }}" />
<input class="djal-selected-text" type="hidden" value="{{ params.selected_text|default_if_none:'' }}" />
<select
class="django-admin-list-filter-ajax"
data-ajax--cache="true"
Expand Down
62 changes: 62 additions & 0 deletions tests/testproject/testapp/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,65 @@ def test_order_admin_nested_ajax_filter(admin_client):
assert results
supplier_names = set(Supplier.objects.values_list('name', flat=True))
assert any(result['text'] in supplier_names for result in results)


@pytest.mark.django_db
def test_ajax_filter_repopulation_with_selected_value(admin_client):
"""Test that selected_text is correctly passed for filter repopulation."""
from .factories import CategoryFactory, PostFactory

category = CategoryFactory(name='SelectedCategory')
PostFactory(category=category)

response = admin_client.get(
reverse('admin:testapp_post_changelist'),
{'category__id__exact': category.pk},
)
assert response.status_code == HTTPStatus.OK

changelist = response.context['cl']
filter_specs = changelist.filter_specs

ajax_specs = [spec for spec in filter_specs if isinstance(spec, DALFRelatedFieldAjax)]
category_spec = next((spec for spec in ajax_specs if spec.lookup_kwarg == 'category__id__exact'), None)
assert category_spec is not None

filter_choices = list(category_spec.choices(changelist))
custom_params = filter_choices[-1]

assert custom_params['selected_value'] == str(category.pk)
assert custom_params['selected_text'] == 'SelectedCategory'

content = response.content.decode()
assert 'value="SelectedCategory"' in content


@pytest.mark.django_db
def test_ajax_filter_with_deleted_selected_value(admin_client):
"""Test that deleted selected values are gracefully handled."""
from .factories import CategoryFactory, PostFactory

category = CategoryFactory(name='ToBeDeleted')
PostFactory(category=category)
deleted_pk = category.pk
Post.objects.filter(category=category).delete()
category.delete()

response = admin_client.get(
reverse('admin:testapp_post_changelist'),
{'category__id__exact': deleted_pk},
)
assert response.status_code == HTTPStatus.OK

changelist = response.context['cl']
filter_specs = changelist.filter_specs

ajax_specs = [spec for spec in filter_specs if isinstance(spec, DALFRelatedFieldAjax)]
category_spec = next((spec for spec in ajax_specs if spec.lookup_kwarg == 'category__id__exact'), None)
assert category_spec is not None

filter_choices = list(category_spec.choices(changelist))
custom_params = filter_choices[-1]

assert custom_params['selected_value'] is None
assert custom_params['selected_text'] is None