diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7781d25c..ff7fde53 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,6 +7,7 @@ on: pull_request: branches: - main + - rel/4-x jobs: test: diff --git a/RELEASE.md b/RELEASE.md index 8b951711..b69b2032 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -46,6 +46,19 @@ so if another branch is used, then the documentation will be incomplete. The `release` github workflow will run and publish the release binaries to both test.pypi.org and pypi.org. +#### Release from a different branch + +Ensure that feature branches are merged and that the changelog is up-to-date as required. + +To release from a feature branch (e.g. 'rel/4-x'): + +1. Ensure that the branch is up-to-date locally (`git pull upstream rel/4-x`) +2. Tag the branch as required (`git tag -a 2.29.2 -m "v4.3.11"`) +3. Push tags (`git push --tags upstream`) + +Now release as above but use the appropriate git tag. +Remember to merge the release branch into the `main` branch. + ### Check readthedocs [readthedocs](https://readthedocs.org/projects/django-import-export/) integration is used to publish documentation. diff --git a/docs/changelog.rst b/docs/changelog.rst index 5adb2eb7..0203eeba 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -14,6 +14,11 @@ Changelog - Added support for django 6.0a (`2112 `_) - Removed the deprecated :meth:`~import_export.admin.ExportMixin.get_valid_export_item_pks` method in favour of :meth:`~import_export.admin.ExportMixin.get_queryset` (`1898 `_) +4.3.11 (2025-10-18) +------------------- + +- Fix for export not retaining URI query params (`2097 `_) + 4.3.10 (2025-09-26) ------------------- diff --git a/import_export/admin.py b/import_export/admin.py index 28c4b7e1..e1a0dc5b 100644 --- a/import_export/admin.py +++ b/import_export/admin.py @@ -1,5 +1,6 @@ import logging import warnings +from urllib.parse import urlencode import django from django.conf import settings @@ -906,6 +907,12 @@ class ExportActionMixin(ExportMixin): self.model._meta.model_name, ) ) + + # Preserve admin changelist filters by including request GET parameters + # This fixes issue #2097 where applied filters are lost during export + if request.GET: + export_url += "?" + urlencode(request.GET) + context["export_url"] = export_url return render(request, "admin/import_export/export.html", context=context) diff --git a/tests/core/admin.py b/tests/core/admin.py index d36af50b..0713356f 100644 --- a/tests/core/admin.py +++ b/tests/core/admin.py @@ -33,10 +33,39 @@ class BookNameResource(ModelResource): name = "Export/Import only book names" +class AuthorBirthdayListFilter(admin.SimpleListFilter): + """Example filter which can be used in the Admin UI to filter books by + author birthday.""" + + title = "author's birthday" + parameter_name = "birthday" + + def lookups(self, request, model_admin): + return ( + ("before", "before 1900"), + ("after", "after 1900"), + ) + + def choices(self, cl): + for lookup, title in self.lookup_choices: + yield { + "selected": self.value() == lookup, + "query_string": cl.get_query_string({self.parameter_name: lookup}, []), + "display": title, + } + + def queryset(self, request, queryset): + if self.value() == "before": + return queryset.filter(author__birthday__year__lt=1900) + if self.value() == "after": + return queryset.filter(author__birthday__year__gte=1900) + return queryset + + @admin.register(Book) -class BookAdmin(ImportExportModelAdmin): +class BookAdmin(ExportActionModelAdmin, ImportExportModelAdmin): list_display = ("name", "author", "added") - list_filter = ["categories", "author"] + list_filter = [AuthorBirthdayListFilter, "categories", "author"] resource_classes = [BookResource, BookNameResource] change_list_template = "core/admin/change_list.html" diff --git a/tests/core/tests/admin_integration/test_action_export.py b/tests/core/tests/admin_integration/test_action_export.py index b352d234..2166fae3 100644 --- a/tests/core/tests/admin_integration/test_action_export.py +++ b/tests/core/tests/admin_integration/test_action_export.py @@ -1,10 +1,10 @@ import warnings -from datetime import datetime +from datetime import date, datetime from unittest import mock from unittest.mock import MagicMock, PropertyMock, patch from core.admin import CategoryAdmin -from core.models import Book, Category, UUIDCategory +from core.models import Author, Book, Category, UUIDCategory from core.tests.admin_integration.mixins import AdminTestMixin from django.contrib import admin from django.contrib.admin import AdminSite @@ -230,6 +230,134 @@ class ExportActionAdminIntegrationTest(AdminTestMixin, TestCase): m.get_export_data("0", request, Book.objects.none()) +class TestExportFilterPreservation(AdminTestMixin, TestCase): + """ + Test cases for issue #2097: Admin filters are lost during export actions. + + Tests that admin changelist filters are properly preserved when exporting + selected items through the export action using the AuthorBirthdayListFilter. + """ + + def setUp(self): + super().setUp() + + # Create authors from different eras to test the AuthorBirthdayListFilter + self.old_author1 = Author.objects.create( + name="Old Author 1", birthday=date(1850, 1, 1) + ) + self.old_author2 = Author.objects.create( + name="Old Author 2", birthday=date(1880, 6, 15) + ) + self.new_author1 = Author.objects.create( + name="New Author 1", birthday=date(1950, 3, 10) + ) + self.new_author2 = Author.objects.create( + name="New Author 2", birthday=date(1970, 12, 25) + ) + + # Create books with authors from different eras + self.old_book1 = Book.objects.create(name="Old Book 1", author=self.old_author1) + self.old_book2 = Book.objects.create(name="Old Book 2", author=self.old_author2) + self.new_book1 = Book.objects.create(name="New Book 1", author=self.new_author1) + self.new_book2 = Book.objects.create(name="New Book 2", author=self.new_author2) + + # fields payload for `BookResource` - for `SelectableFieldsExportForm` + self.resource_fields_payload = { + "bookresource_id": True, + "bookresource_name": True, + "bookresource_author": True, + } + + def test_export_action_preserves_admin_filters(self): + """ + Test that admin filters are preserved when exporting selected items. + This reproduces issue #2097 where applied filters are lost during export. + + Uses the AuthorBirthdayListFilter to test filter preservation with books. + The issue occurs when: + 1. User applies filters in admin changelist (authors born before 1900) + 2. User selects items from filtered results + 3. User chooses "Export selected items" action + 4. The export URL loses the filter context, causing unfiltered export + """ + # Step 1: Simulate POST action with AuthorBirthdayListFilter applied + data = { + "action": ["export_admin_action"], + "_selected_action": [ + str(self.old_book1.id), + str(self.old_book2.id), + ], + } + + # Add filter parameters to simulate applied admin filters + filter_params = "?birthday=before" + url_with_filters = self.core_book_url + filter_params + + # Make the request with filters applied + response = self._post_url_response(url_with_filters, data) + + # Should get an export form + self.assertIn("form", response.context) + + # Check that export_url preserves the filter parameters + export_url = response.context.get("export_url", "") + self.assertIn( + "birthday=before", + export_url, + f"Export URL should preserve AuthorBirthdayListFilter parameters. " + f"Got URL: '{export_url}'. Filter preservation is working!", + ) + + def test_export_action_filter_preservation_end_to_end(self): + """ + Test the complete filter preservation workflow from action to final export. + This test follows the complete flow: action -> form -> export with filters. + """ + # Step 1: First trigger the export action with filters + action_data = { + "action": ["export_admin_action"], + "_selected_action": [ + str(self.old_book1.id), + str(self.old_book2.id), + ], + } + + # POST to changelist with filter to get export form + filter_params = "?birthday=before" + url_with_filters = self.core_book_url + filter_params + action_response = self._post_url_response(url_with_filters, action_data) + + # Should get an export form with preserved filter URL + self.assertIn("form", action_response.context) + export_url = action_response.context.get("export_url", "") + self.assertIn("birthday=before", export_url) + + # Step 2: Now submit the export form to the preserved URL + export_data = { + "format": "0", + "export_items": [str(self.old_book1.id), str(self.old_book2.id)], + **self.resource_fields_payload, + } + + # POST to the export URL that should have preserved filters + final_response = self._post_url_response(export_url, export_data) + + # Should get CSV export that respects the filter context + self.assertEqual(final_response["Content-Type"], "text/csv") + content = final_response.content.decode() + + # Verify the export contains the expected filtered data + lines = content.strip().split("\n") + if len(lines) > 1: + data_lines = lines[1:] # Remove header + # Should only contain the 2 selected books + self.assertEqual( + len(data_lines), + 2, + f"Filter preservation working! Expected 2 books, got {len(data_lines)}", + ) + + class TestExportButtonOnChangeForm(AdminTestMixin, TestCase): def setUp(self): super().setUp()