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 @@
{% for field in form.visible_fields %} -
- {{ field.errors }} - - {{ field.label_tag }} - - {% if field.field.widget.attrs.readonly %} - {{ field.field.value }} - {{ field.as_hidden }} - {% else %} - {{ field }} - {% endif %} - - {% if field.field.help_text %} -

{{ field.field.help_text|safe }}

- {% endif %} -
+ {% if not field.field.is_selectable_field %} +
+ {{ field.errors }} + + {{ field.label_tag }} + + {% if field.field.widget.attrs.readonly %} + {{ field.field.value }} + {{ field.as_hidden }} + {% else %} + {{ field }} + {% endif %} + + {% if field.field.help_text %} +

{{ field.field.help_text|safe }}

+ {% endif %} +
+ {% endif %} {% endfor %} {% for field in form.hidden_fields %} {{ field }} {% endfor %}
+
+ {{ 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 %} +
+ {% if resource.grouper %} +
+ {{ resource.grouper }} + {% for field in resource.list %} +
+ {{ field.errors }} + + {{ field.label_tag }} + {{ field }} +
+ {% endfor %} +
+ {% endif %} +
+{% 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)