From e3a3dc0cd01a87162ce9e19a533eab084646efd6 Mon Sep 17 00:00:00 2001
From: cocorocho
Date: Tue, 9 Jan 2024 10:51:53 -0300
Subject: [PATCH 1/6] add selectable fields form for admin export view
---
import_export/admin.py | 5 +-
import_export/forms.py | 162 ++++++++++++++++++
import_export/mixins.py | 17 +-
import_export/resources.py | 26 ++-
.../templates/admin/import_export/export.html | 38 ++--
.../import_export/resource_fields_list.html | 20 ++-
.../selectable_resource_export_fields.html | 19 ++
tests/core/tests/test_forms.py | 129 ++++++++++++++
tests/core/tests/test_mixins.py | 4 +-
9 files changed, 382 insertions(+), 38 deletions(-)
create mode 100644 import_export/templates/admin/import_export/selectable_resource_export_fields.html
diff --git a/import_export/admin.py b/import_export/admin.py
index 6bd8ff4a4..f9bdb2f4f 100644
--- a/import_export/admin.py
+++ b/import_export/admin.py
@@ -18,7 +18,7 @@
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_POST
-from .forms import ConfirmImportForm, ExportForm, ImportForm
+from .forms import ConfirmImportForm, ImportForm, SelectableFieldsExportForm
from .mixins import BaseExportMixin, BaseImportMixin
from .results import RowResult
from .signals import post_export, post_import
@@ -592,7 +592,8 @@ class ExportMixin(BaseExportMixin, ImportExportMixinBase):
#: export data encoding
to_encoding = None
#: form class to use for the initial import step
- export_form_class = ExportForm
+ #: Use `ExportForm` if you would like to disable selectable fields feature
+ export_form_class = SelectableFieldsExportForm
def get_urls(self):
urls = super().get_urls()
diff --git a/import_export/forms.py b/import_export/forms.py
index 1c8c80e3a..584f80bf2 100644
--- a/import_export/forms.py
+++ b/import_export/forms.py
@@ -1,9 +1,13 @@
import os.path
+from copy import deepcopy
+from typing import Any, Iterable
from django import forms
from django.conf import settings
from django.utils.translation import gettext_lazy as _
+from .resources import ModelResource
+
class ImportExportFormBase(forms.Form):
resource = forms.ChoiceField(
@@ -91,3 +95,161 @@ class ExportForm(ImportExportFormBase):
export_items = forms.MultipleChoiceField(
widget=forms.MultipleHiddenInput(), required=False
)
+
+
+class SelectableFieldsExportForm(ExportForm):
+ def __init__(self, formats, resources, *args, **kwargs):
+ super().__init__(formats, resources, *args, **kwargs)
+ self._init_selectable_fields(resources)
+
+ def _init_selectable_fields(self, resources: Iterable[ModelResource]) -> None:
+ """
+ Create `BooleanField(s)` for resource fields
+ """
+ self.resources = resources
+ self.is_selectable_fields_form = True
+ self.resource_fields = {resource.__name__: list() for resource in resources}
+
+ for resource in resources:
+ self._create_boolean_fields(resource)
+
+ def _create_boolean_fields(self, resource: ModelResource) -> None:
+ # Initiate resource to get ordered export fields
+ fields = resource().get_export_order()
+
+ boolean_fields = [] # will be used for ordering the fields
+
+ for field in fields:
+ field_name = self.create_boolean_field_name(resource, field)
+ boolean_field = forms.BooleanField(
+ label=field.replace("_", " ").title(),
+ initial=True,
+ required=False,
+ )
+ # These attributes will be used for rendering in template
+ boolean_field.is_selectable_field = True
+ boolean_field.resource_name = resource.__name__
+
+ self.fields[field_name] = boolean_field
+ boolean_fields.append(field_name)
+
+ self.order_fields(boolean_fields) # Order fields by boolean fields
+
+ self.resource_fields[resource.__name__] = boolean_fields
+
+ @staticmethod
+ def create_boolean_field_name(resource: ModelResource, field_name: str) -> str:
+ """
+ Create field name by combining `resource_name` + `field_name` to prevent
+ confliction between resource fields with same name
+
+ Example:
+ BookResource + name -> bookresource_name
+ BookResourceWithNames + name -> bookresourcewithnames_name
+ """
+ return resource.__name__.lower() + "_" + field_name
+
+ def full_clean(self) -> None:
+ return super().full_clean()
+
+ def clean(self) -> dict[str, Any]:
+ selected_resource = self.get_selected_resource()
+
+ if selected_resource:
+ # Remove fields for not selected resources
+ self._remove_unselected_resource_fields(selected_resource)
+ # Normalize resource field names
+ self._normalize_resource_fields(selected_resource)
+
+ # Validate at least one field is selected for selected resource
+ # Run this validation only if form.data is present
+ # and data contains any of fields
+ if self.data is not None and any(
+ [
+ f
+ for f in self.data
+ if f in self.resource_fields[selected_resource.__name__]
+ ]
+ ):
+ self._validate_any_field_selected(selected_resource)
+
+ return self.cleaned_data
+
+ def _remove_unselected_resource_fields(
+ self, selected_resource: ModelResource
+ ) -> None:
+ """
+ Remove boolean fields except the fields for selected resource
+ """
+ _cleaned_data = deepcopy(self.cleaned_data)
+
+ for resource_name, fields in self.resource_fields.items():
+ if selected_resource.__name__ == resource_name:
+ # Skip selected resource
+ continue
+
+ for field in fields:
+ del _cleaned_data[field]
+
+ self.cleaned_data = _cleaned_data
+
+ def get_selected_resource(self) -> ModelResource | None:
+ """
+ Get selected resource
+ """
+ if not getattr(self, "cleaned_data", None):
+ raise forms.ValidationError(
+ _("Form is not validated, call `is_valid` first")
+ )
+
+ # Return selected resource by index
+ resource_index = 0
+ if "resource" in self.cleaned_data:
+ try:
+ resource_index = int(self.cleaned_data["resource"])
+ except ValueError:
+ pass
+ return self.resources[resource_index]
+
+ def _normalize_resource_fields(self, selected_resource: ModelResource) -> dict:
+ """
+ Field names are combination of resource_name + field_name,
+ normalize field names by removing resource name
+ """
+ selected_resource_name = selected_resource.__name__.lower() + "_"
+ _cleaned_data = {}
+ self._selected_resource_fields = []
+
+ for k, v in self.cleaned_data.items():
+ if selected_resource_name in k:
+ field_name = k.replace(selected_resource_name, "")
+ _cleaned_data[field_name] = v
+ if v is True:
+ # Add to _selected_resource_fields to determine what
+ # fields were selected for export
+ self._selected_resource_fields.append(field_name)
+ continue
+ _cleaned_data[k] = v
+
+ self.cleaned_data = _cleaned_data
+
+ def get_selected_resource_export_fields(self) -> list[str]:
+ selected_resource = self.get_selected_resource()
+ # Initialize resource to use `get_export_order` method
+ resource_fields = selected_resource().get_export_order()
+ return [
+ field
+ for field, value in self.cleaned_data.items()
+ if field in resource_fields and value is True
+ ]
+
+ def _validate_any_field_selected(self, resource) -> None:
+ resource_fields = [field for field in resource().get_export_order()]
+ if not any([v for k, v in self.cleaned_data.items() if k in resource_fields]):
+ raise forms.ValidationError(
+ _("""Select at least 1 field for "%(resource_name)s" to export"""),
+ code="invalid",
+ params={
+ "resource_name": resource.get_display_name(),
+ },
+ )
diff --git a/import_export/mixins.py b/import_export/mixins.py
index 4443fc95b..ef7362e27 100644
--- a/import_export/mixins.py
+++ b/import_export/mixins.py
@@ -7,7 +7,7 @@
from django.views.generic.edit import FormView
from .formats import base_formats
-from .forms import ExportForm
+from .forms import SelectableFieldsExportForm
from .resources import modelresource_factory
from .signals import post_export
@@ -102,12 +102,23 @@ def choose_export_resource_class(self, form):
def get_export_resource_kwargs(self, request, **kwargs):
return self.get_resource_kwargs(request, **kwargs)
+ def get_export_resource_fields_from_from(self, form) -> list[str] | None:
+ if isinstance(form, SelectableFieldsExportForm):
+ export_fields = form.get_selected_resource_export_fields()
+ if export_fields:
+ return export_fields
+
+ return
+
def get_data_for_export(self, request, queryset, **kwargs):
export_form = kwargs.get("export_form")
export_class = self.choose_export_resource_class(export_form)
export_resource_kwargs = self.get_export_resource_kwargs(request, **kwargs)
+ export_fields = self.get_export_resource_fields_from_from(export_form)
cls = export_class(**export_resource_kwargs)
- export_data = cls.export(queryset=queryset, **kwargs)
+ export_data = cls.export(
+ queryset=queryset, export_fields=export_fields, **kwargs
+ )
return export_data
def get_export_filename(self, file_format):
@@ -121,7 +132,7 @@ def get_export_filename(self, file_format):
class ExportViewMixin(BaseExportMixin):
- form_class = ExportForm
+ form_class = SelectableFieldsExportForm
def get_export_data(self, file_format, queryset, *args, **kwargs):
"""
diff --git a/import_export/resources.py b/import_export/resources.py
index 1c0564b83..f39081017 100644
--- a/import_export/resources.py
+++ b/import_export/resources.py
@@ -988,13 +988,24 @@ def export_field(self, field, instance):
def get_export_fields(self):
return [self.fields[f] for f in self.get_export_order()]
- def export_resource(self, instance):
- return [
- self.export_field(field, instance) for field in self.get_export_fields()
- ]
+ def export_resource(self, instance, fields: list[str] | None = None):
+ export_fields = self.get_export_fields()
+
+ if isinstance(fields, list) and fields:
+ return [
+ self.export_field(field, instance)
+ for field in export_fields
+ if field.column_name in fields
+ ]
+
+ return [self.export_field(field, instance) for field in export_fields]
- def get_export_headers(self):
+ def get_export_headers(self, fields: list[str] | None = None):
headers = [force_str(field.column_name) for field in self.get_export_fields()]
+
+ if isinstance(fields, list) and fields:
+ return [f for f in headers if f in fields]
+
return headers
def get_user_visible_headers(self):
@@ -1036,11 +1047,12 @@ def export(self, queryset=None, **kwargs):
if queryset is None:
queryset = self.get_queryset()
queryset = self.filter_export(queryset, **kwargs)
- headers = self.get_export_headers()
+ export_fields = kwargs.get("export_fields", None)
+ headers = self.get_export_headers(fields=export_fields)
dataset = tablib.Dataset(headers=headers)
for obj in self.iter_queryset(queryset):
- dataset.append(self.export_resource(obj))
+ dataset.append(self.export_resource(obj, fields=export_fields))
self.after_export(queryset, dataset, **kwargs)
diff --git a/import_export/templates/admin/import_export/export.html b/import_export/templates/admin/import_export/export.html
index e692fabc6..089b6c348 100644
--- a/import_export/templates/admin/import_export/export.html
+++ b/import_export/templates/admin/import_export/export.html
@@ -30,28 +30,34 @@
+
+ {{ form.non_field_errors }}
+
+
diff --git a/import_export/templates/admin/import_export/resource_fields_list.html b/import_export/templates/admin/import_export/resource_fields_list.html
index 3981a9d96..19a5792bd 100644
--- a/import_export/templates/admin/import_export/resource_fields_list.html
+++ b/import_export/templates/admin/import_export/resource_fields_list.html
@@ -7,15 +7,19 @@
{% translate "This importer will import the following fields: " %}
{% endif %}
- {% if fields_list|length <= 1 %}
- {{ fields_list.0.1|join:", " }}
+ {% if form.is_selectable_fields_form %}
+ {% include "admin/import_export/selectable_resource_export_fields.html" %}
{% else %}
-
- {% for resource, fields in fields_list %}
- - {{ resource }}
- {{ fields|join:", " }}
- {% endfor %}
-
+ {% if fields_list|length <= 1 %}
+ {{ fields_list.0.1|join:", " }}
+ {% else %}
+
+ {% for resource, fields in fields_list %}
+ - {{ resource }}
+ {{ fields|join:", " }}
+ {% endfor %}
+
+ {% endif %}
{% endif %}
{% endblock %}
diff --git a/import_export/templates/admin/import_export/selectable_resource_export_fields.html b/import_export/templates/admin/import_export/selectable_resource_export_fields.html
new file mode 100644
index 000000000..983668b38
--- /dev/null
+++ b/import_export/templates/admin/import_export/selectable_resource_export_fields.html
@@ -0,0 +1,19 @@
+{% regroup form by field.resource_name as selectable_fields_list %}
+
+{% for resource in selectable_fields_list %}
+
+{% endfor %}
\ No newline at end of file
diff --git a/tests/core/tests/test_forms.py b/tests/core/tests/test_forms.py
index 15aec6a62..3494f068a 100644
--- a/tests/core/tests/test_forms.py
+++ b/tests/core/tests/test_forms.py
@@ -1,8 +1,11 @@
+import django.forms
from django.test import TestCase
from import_export import forms, resources
from import_export.formats.base_formats import CSV
+from .resources import BookResource, BookResourceWithStoreInstance
+
class MyResource(resources.ModelResource):
class Meta:
@@ -28,3 +31,129 @@ def test_formbase_init_two_resources(self):
form.fields["resource"].choices,
[(0, "ModelResource"), (1, "My super resource")],
)
+
+
+class SelectableFieldsExportFormTest(TestCase):
+ @classmethod
+ def setUpTestData(cls) -> None:
+ cls.resources = (BookResource, BookResourceWithStoreInstance)
+ cls.form = forms.SelectableFieldsExportForm(
+ formats=(CSV,),
+ resources=cls.resources,
+ )
+
+ def test_create_boolean_fields(self) -> None:
+ form_fields = self.form.fields
+
+ for resource in self.resources:
+ fields = resource().get_export_order()
+ for field in fields:
+ field_name = forms.SelectableFieldsExportForm.create_boolean_field_name(
+ resource, field
+ )
+ self.assertIn(field_name, form_fields)
+ form_field = form_fields[field_name]
+ self.assertIsInstance(form_field, django.forms.BooleanField)
+
+ def test_form_raises_validation_error_when_no_resource_fields_are_selected(
+ self,
+ ) -> None:
+ data = {"resource": "0", "format": "0"}
+
+ form = forms.SelectableFieldsExportForm(
+ formats=(CSV,), resources=self.resources, data=data
+ )
+
+ # Form should be valid because no fields are provided in `data`
+ # so any field validation won't run
+ self.assertTrue(form.is_valid())
+
+ # Add field to data, form will run any field selected validation now
+ data["bookresource_id"] = False
+ form = forms.SelectableFieldsExportForm(
+ formats=(CSV,), resources=self.resources, data=data
+ )
+ self.assertFalse(form.is_valid())
+ self.assertTrue("Select at least 1 field for" in form.errors.as_text())
+
+ def test_remove_unselected_resource_fields_on_validation(self):
+ data = {"resource": "0", "format": "0"}
+
+ # Add all field values to form data for validation
+ for resource in self.resources:
+ for field in resource().get_export_order():
+ data[
+ forms.SelectableFieldsExportForm.create_boolean_field_name(
+ resource, field
+ )
+ ] = True
+
+ form = forms.SelectableFieldsExportForm(
+ formats=(CSV,), resources=self.resources, data=data
+ )
+
+ self.assertTrue(form.is_valid())
+
+ selected_resource = self.resources[0]
+ selected_resource_fields = selected_resource().get_export_order()
+ not_selected_resource = self.resources[1] # resource on index 0 was selected
+
+ for field in not_selected_resource().get_export_order():
+ # Only assert fields which doesn't exist in selected resource's fields
+ if field not in selected_resource_fields:
+ self.assertNotIn(field, form.cleaned_data)
+
+ def test_normalize_resource_field_names(self) -> None:
+ """
+ Field names are combination of resource's name and field name.
+ After validation, fields that belong to unselected resources are removed
+ and resource name is removed from field names
+ """
+
+ data = {"resource": "0", "format": "0"}
+
+ # Add all field values to form data for validation
+ for resource in self.resources:
+ for field in resource().get_export_order():
+ data[
+ forms.SelectableFieldsExportForm.create_boolean_field_name(
+ resource, field
+ )
+ ] = "on"
+
+ form = forms.SelectableFieldsExportForm(
+ formats=(CSV,), resources=self.resources, data=data
+ )
+ self.assertTrue(form.is_valid())
+ selected_resource = self.resources[0]
+
+ for field in selected_resource().get_export_order():
+ self.assertIn(field, form.cleaned_data)
+
+ def test_get_selected_resource_fields_without_validation_raises_validation_error(
+ self,
+ ) -> None:
+ self.assertRaises(
+ django.forms.ValidationError, self.form.get_selected_resource_export_fields
+ )
+
+ def test_get_selected_resrource_fields(self) -> None:
+ data = {"resource": "0", "format": "0"}
+ form = forms.SelectableFieldsExportForm(
+ formats=(CSV,), resources=self.resources, data=data
+ )
+ for resource in self.resources:
+ for field in resource().get_export_order():
+ data[
+ forms.SelectableFieldsExportForm.create_boolean_field_name(
+ resource, field
+ )
+ ] = "on"
+
+ self.assertTrue(form.is_valid())
+ selected_resource = self.resources[0]()
+
+ self.assertEqual(
+ form.get_selected_resource_export_fields(),
+ list(selected_resource.get_export_order()),
+ )
diff --git a/tests/core/tests/test_mixins.py b/tests/core/tests/test_mixins.py
index f253d1da6..f6bd11b99 100644
--- a/tests/core/tests/test_mixins.py
+++ b/tests/core/tests/test_mixins.py
@@ -206,7 +206,7 @@ class BaseModelAdminBothResourceTest(mixins.BaseExportMixin):
class BaseModelExportChooseTest(mixins.BaseExportMixin):
resource_classes = [resources.Resource, FooResource]
- @mock.patch("import_export.admin.ExportForm")
+ @mock.patch("import_export.admin.SelectableFieldsExportForm")
def test_choose_export_resource_class(self, form):
"""Test choose_export_resource_class() method"""
admin = self.BaseModelExportChooseTest()
@@ -288,7 +288,7 @@ class TestExportForm(forms.ExportForm):
def test_get_export_form(self):
m = admin.ExportMixin()
- self.assertEqual(forms.ExportForm, m.get_export_form_class())
+ self.assertEqual(admin.ExportMixin.export_form_class, m.get_export_form_class())
def test_get_export_form_with_custom_form(self):
m = self.TestExportMixin(self.TestExportForm)
From 4926a12752601e00c0f82b879f5280ecb71a126d Mon Sep 17 00:00:00 2001
From: cocorocho
Date: Tue, 9 Jan 2024 15:49:54 -0300
Subject: [PATCH 2/6] correct documentation
---
import_export/admin.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/import_export/admin.py b/import_export/admin.py
index f9bdb2f4f..fe9ac5822 100644
--- a/import_export/admin.py
+++ b/import_export/admin.py
@@ -591,7 +591,7 @@ class ExportMixin(BaseExportMixin, ImportExportMixinBase):
export_template_name = "admin/import_export/export.html"
#: export data encoding
to_encoding = None
- #: form class to use for the initial import step
+ #: form class to use for the initial export step
#: Use `ExportForm` if you would like to disable selectable fields feature
export_form_class = SelectableFieldsExportForm
From f8bb578f7793589ad5219b717e417dedc1078822 Mon Sep 17 00:00:00 2001
From: cocorocho
Date: Tue, 9 Jan 2024 15:50:32 -0300
Subject: [PATCH 3/6] run any field checked validation on clean
---
import_export/forms.py | 16 +++++-----------
1 file changed, 5 insertions(+), 11 deletions(-)
diff --git a/import_export/forms.py b/import_export/forms.py
index 584f80bf2..0d3b7ebc8 100644
--- a/import_export/forms.py
+++ b/import_export/forms.py
@@ -160,18 +160,8 @@ def clean(self) -> dict[str, Any]:
self._remove_unselected_resource_fields(selected_resource)
# Normalize resource field names
self._normalize_resource_fields(selected_resource)
-
# Validate at least one field is selected for selected resource
- # Run this validation only if form.data is present
- # and data contains any of fields
- if self.data is not None and any(
- [
- f
- for f in self.data
- if f in self.resource_fields[selected_resource.__name__]
- ]
- ):
- self._validate_any_field_selected(selected_resource)
+ self._validate_any_field_selected(selected_resource)
return self.cleaned_data
@@ -244,7 +234,11 @@ def get_selected_resource_export_fields(self) -> list[str]:
]
def _validate_any_field_selected(self, resource) -> None:
+ """
+ Validate if any field for resource was selected in form data
+ """
resource_fields = [field for field in resource().get_export_order()]
+
if not any([v for k, v in self.cleaned_data.items() if k in resource_fields]):
raise forms.ValidationError(
_("""Select at least 1 field for "%(resource_name)s" to export"""),
From d1037530bae4c17e4e5d7533e8aeada0b9eacc06 Mon Sep 17 00:00:00 2001
From: cocorocho
Date: Tue, 9 Jan 2024 15:51:24 -0300
Subject: [PATCH 4/6] change tests accordingly for SelectableFieldsExportForm
---
.../tests/admin_integration/test_action.py | 12 +++++-
.../tests/admin_integration/test_export.py | 41 +++++++++++++------
tests/core/tests/test_forms.py | 13 +-----
tests/core/tests/test_mixins.py | 7 ++--
4 files changed, 44 insertions(+), 29 deletions(-)
diff --git a/tests/core/tests/admin_integration/test_action.py b/tests/core/tests/admin_integration/test_action.py
index 6e3d0d9f1..da7837550 100644
--- a/tests/core/tests/admin_integration/test_action.py
+++ b/tests/core/tests/admin_integration/test_action.py
@@ -22,6 +22,12 @@ def setUp(self):
super().setUp()
self.cat1 = Category.objects.create(name="Cat 1")
self.cat2 = Category.objects.create(name="Cat 2")
+ # fields payload for `CategoryResource` -
+ # to export using `SelectableFieldsExportForm`
+ self.resource_fields_payload = {
+ "categoryresource_id": True,
+ "categoryresource_name": True,
+ }
def _check_export_response(self, response):
self.assertContains(response, self.cat1.name, status_code=200)
@@ -78,7 +84,11 @@ def test_export_displays_ui_select_page_multiple_items(self):
@ignore_widget_deprecation_warning
def test_export_post(self):
# create a POST request with data selected from the 'action' export
- data = {"format": "0", "export_items": [str(self.cat1.id)]}
+ data = {
+ "format": "0",
+ "export_items": [str(self.cat1.id)],
+ **self.resource_fields_payload,
+ }
date_str = datetime.now().strftime("%Y-%m-%d")
response = self.client.post("/admin/core/category/export/", data)
self.assertEqual(response.status_code, 200)
diff --git a/tests/core/tests/admin_integration/test_export.py b/tests/core/tests/admin_integration/test_export.py
index 9cc921bba..f6b543138 100644
--- a/tests/core/tests/admin_integration/test_export.py
+++ b/tests/core/tests/admin_integration/test_export.py
@@ -28,6 +28,15 @@
class ExportAdminIntegrationTest(AdminTestMixin, TestCase):
+ def setUp(self) -> None:
+ super().setUp()
+ self.bookresource_export_fields_payload = {
+ "bookresource_id": True,
+ "bookresource_name": True,
+ "bookresource_author_email": True,
+ "bookresource_categories": True,
+ }
+
def test_export(self):
response = self.client.get("/admin/core/book/export/")
self.assertEqual(response.status_code, 200)
@@ -35,9 +44,7 @@ def test_export(self):
form = response.context["form"]
self.assertEqual(2, len(form.fields["resource"].choices))
- data = {
- "format": "0",
- }
+ data = {"format": "0", **self.bookresource_export_fields_payload}
date_str = datetime.now().strftime("%Y-%m-%d")
# Should not contain COUNT queries from ModelAdmin.get_results()
with self.assertNumQueries(6):
@@ -50,8 +57,7 @@ def test_export(self):
'attachment; filename="Book-{}.csv"'.format(date_str),
)
self.assertEqual(
- b"id,name,author,author_email,imported,published,"
- b"published_time,price,added,categories\r\n",
+ b"id,name,author_email,categories\r\n",
response.content,
)
@@ -139,6 +145,9 @@ def test_export_second_resource(self):
data = {
"format": "0",
"resource": 1,
+ # Second resource is `BookNameResource`
+ "booknameresource_id": True,
+ "booknameresource_name": True,
}
date_str = datetime.now().strftime("%Y-%m-%d")
response = self.client.post("/admin/core/book/export/", data)
@@ -195,7 +204,7 @@ def test_returns_xlsx_export(self):
self.assertEqual(response.status_code, 200)
xlsx_index = self._get_input_format_index("xlsx")
- data = {"format": str(xlsx_index)}
+ data = {"format": str(xlsx_index), **self.bookresource_export_fields_payload}
response = self.client.post("/admin/core/book/export/", data)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.has_header("Content-Disposition"))
@@ -214,7 +223,7 @@ def test_export_escape_formulae(self):
self.assertEqual(response.status_code, 200)
xlsx_index = self._get_input_format_index("xlsx")
- data = {"format": str(xlsx_index)}
+ data = {"format": str(xlsx_index), **self.bookresource_export_fields_payload}
response = self.client.post("/admin/core/book/export/", data)
self.assertEqual(response.status_code, 200)
content = response.content
@@ -230,10 +239,14 @@ def test_export_escape_formulae_csv(self):
self.assertEqual(response.status_code, 200)
index = self._get_input_format_index("csv")
- data = {"format": str(index)}
+ data = {
+ "format": str(index),
+ "bookresource_id": True,
+ "bookresource_name": True,
+ }
response = self.client.post("/admin/core/book/export/", data)
self.assertIn(
- f"{b1.id},SUM(1+1),,,0,,,,,\r\n".encode(),
+ f"{b1.id},SUM(1+1)\r\n".encode(),
response.content,
)
@@ -245,10 +258,14 @@ def test_export_escape_formulae_csv_false(self):
self.assertEqual(response.status_code, 200)
index = self._get_input_format_index("csv")
- data = {"format": str(index)}
+ data = {
+ "format": str(index),
+ "bookresource_id": True,
+ "bookresource_name": True,
+ }
response = self.client.post("/admin/core/book/export/", data)
self.assertIn(
- f"{b1.id},=SUM(1+1),,,0,,,,,\r\n".encode(),
+ f"{b1.id},=SUM(1+1)\r\n".encode(),
response.content,
)
@@ -282,7 +299,7 @@ def test_export_filters_by_form_param(self):
class TestExportEncoding(TestCase):
mock_request = MagicMock(spec=HttpRequest)
- mock_request.POST = {"format": 0}
+ mock_request.POST = {"format": 0, "bookresource_id": True}
class TestMixin(ExportMixin):
model = Book
diff --git a/tests/core/tests/test_forms.py b/tests/core/tests/test_forms.py
index 3494f068a..7985e7de0 100644
--- a/tests/core/tests/test_forms.py
+++ b/tests/core/tests/test_forms.py
@@ -58,18 +58,7 @@ def test_create_boolean_fields(self) -> None:
def test_form_raises_validation_error_when_no_resource_fields_are_selected(
self,
) -> None:
- data = {"resource": "0", "format": "0"}
-
- form = forms.SelectableFieldsExportForm(
- formats=(CSV,), resources=self.resources, data=data
- )
-
- # Form should be valid because no fields are provided in `data`
- # so any field validation won't run
- self.assertTrue(form.is_valid())
-
- # Add field to data, form will run any field selected validation now
- data["bookresource_id"] = False
+ data = {"resource": "0", "format": "0", "bookresource_id": False}
form = forms.SelectableFieldsExportForm(
formats=(CSV,), resources=self.resources, data=data
)
diff --git a/tests/core/tests/test_mixins.py b/tests/core/tests/test_mixins.py
index f6bd11b99..5df2a74a7 100644
--- a/tests/core/tests/test_mixins.py
+++ b/tests/core/tests/test_mixins.py
@@ -21,9 +21,10 @@ def setUp(self):
self.url = reverse("export-category")
self.cat1 = Category.objects.create(name="Cat 1")
self.cat2 = Category.objects.create(name="Cat 2")
+ self.resource = modelresource_factory(Category)
self.form = ExportViewMixinTest.TestExportForm(
formats=formats.base_formats.DEFAULT_FORMATS,
- resources=[modelresource_factory(Category)],
+ resources=[self.resource],
)
self.form.cleaned_data["format"] = "0"
@@ -34,9 +35,7 @@ def test_get(self):
@ignore_widget_deprecation_warning
def test_post(self):
- data = {
- "format": "0",
- }
+ data = {"format": "0", "categoryresource_id": True}
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=DeprecationWarning)
response = self.client.post(self.url, data)
From 539667dbbc7227c86b013431e90db4487bdb0a8f Mon Sep 17 00:00:00 2001
From: cocorocho
Date: Tue, 9 Jan 2024 16:53:50 -0300
Subject: [PATCH 5/6] remove type annotations for python 3.8
---
import_export/forms.py | 8 ++++----
import_export/mixins.py | 2 +-
import_export/resources.py | 4 ++--
3 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/import_export/forms.py b/import_export/forms.py
index 0d3b7ebc8..6bdfe0ff5 100644
--- a/import_export/forms.py
+++ b/import_export/forms.py
@@ -1,6 +1,6 @@
import os.path
from copy import deepcopy
-from typing import Any, Iterable
+from typing import Iterable
from django import forms
from django.conf import settings
@@ -152,7 +152,7 @@ def create_boolean_field_name(resource: ModelResource, field_name: str) -> str:
def full_clean(self) -> None:
return super().full_clean()
- def clean(self) -> dict[str, Any]:
+ def clean(self):
selected_resource = self.get_selected_resource()
if selected_resource:
@@ -183,7 +183,7 @@ def _remove_unselected_resource_fields(
self.cleaned_data = _cleaned_data
- def get_selected_resource(self) -> ModelResource | None:
+ def get_selected_resource(self):
"""
Get selected resource
"""
@@ -223,7 +223,7 @@ def _normalize_resource_fields(self, selected_resource: ModelResource) -> dict:
self.cleaned_data = _cleaned_data
- def get_selected_resource_export_fields(self) -> list[str]:
+ def get_selected_resource_export_fields(self):
selected_resource = self.get_selected_resource()
# Initialize resource to use `get_export_order` method
resource_fields = selected_resource().get_export_order()
diff --git a/import_export/mixins.py b/import_export/mixins.py
index ef7362e27..6356a278e 100644
--- a/import_export/mixins.py
+++ b/import_export/mixins.py
@@ -102,7 +102,7 @@ def choose_export_resource_class(self, form):
def get_export_resource_kwargs(self, request, **kwargs):
return self.get_resource_kwargs(request, **kwargs)
- def get_export_resource_fields_from_from(self, form) -> list[str] | None:
+ def get_export_resource_fields_from_from(self, form):
if isinstance(form, SelectableFieldsExportForm):
export_fields = form.get_selected_resource_export_fields()
if export_fields:
diff --git a/import_export/resources.py b/import_export/resources.py
index f39081017..d4a65be3f 100644
--- a/import_export/resources.py
+++ b/import_export/resources.py
@@ -988,7 +988,7 @@ def export_field(self, field, instance):
def get_export_fields(self):
return [self.fields[f] for f in self.get_export_order()]
- def export_resource(self, instance, fields: list[str] | None = None):
+ def export_resource(self, instance, fields=None):
export_fields = self.get_export_fields()
if isinstance(fields, list) and fields:
@@ -1000,7 +1000,7 @@ def export_resource(self, instance, fields: list[str] | None = None):
return [self.export_field(field, instance) for field in export_fields]
- def get_export_headers(self, fields: list[str] | None = None):
+ def get_export_headers(self, fields=None):
headers = [force_str(field.column_name) for field in self.get_export_fields()]
if isinstance(fields, list) and fields:
From b73130f954357040532ccd81250ee089f8962f2d Mon Sep 17 00:00:00 2001
From: cocorocho
Date: Tue, 9 Jan 2024 16:55:08 -0300
Subject: [PATCH 6/6] add changes to changelog
---
docs/changelog.rst | 1 +
1 file changed, 1 insertion(+)
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 16fa9844e..0e2aa5c41 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -6,6 +6,7 @@ Please refer to :doc:`release notes`.
4.0.0-beta.3 (unreleased)
-------------------------
+- Added feature: selectable fields for admin export view (#1538)
- Fix issue where declared Resource fields not defined in ``fields`` are still imported (#1702)
- Added customizable ``MediaStorage`` (#1708)
- Relocated admin integration section from advanced_usage.rst into new file (#1713)