From 40b44a71edaa3abec2c07ec106577047b4c483b3 Mon Sep 17 00:00:00 2001
From: matthewhegarty
Date: Wed, 28 Apr 2021 19:53:48 +0100
Subject: [PATCH 01/77] Use 'create' flag instead of instance.pk (#1274)
changed 'is_create' to mandatory arg
added 'pk_attr' property
reverted self.save_instance() call params
added tests for UUID model field
fixed test_resources.py imports
added is_create flag
refactored migration name
removed django_extensions from settings
documented breaking change in changelog
removed unused pk_attr attribute
fixed indentation
---
docs/changelog.rst | 11 +++-
import_export/resources.py | 16 +++--
tests/core/migrations/0010_uuidbook.py | 21 +++++++
tests/core/models.py | 7 +++
tests/core/tests/test_resources.py | 83 +++++++++++++++++++++++---
5 files changed, 124 insertions(+), 14 deletions(-)
create mode 100644 tests/core/migrations/0010_uuidbook.py
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 2ff4bf63f..ace32b905 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -1,11 +1,18 @@
Changelog
=========
-2.7.2 (unreleased)
+3.0.0 (unreleased)
------------------
-- Nothing changed yet.
+Breaking changes
+################
+This release makes the following changes to the API. You may need to update your implementation to
+accommodate these changes.
+
+- Use 'create' flag instead of instance.pk (#1362)
+ - ``import_export.resources.save_instance()`` now takes an additional mandatory argument: `is_create`.
+ If you have over-ridden `save_instance()` in your own code, you will need to add this new argument.
2.7.1 (2021-12-23)
------------------
diff --git a/import_export/resources.py b/import_export/resources.py
index e00d3ba0c..6fb574ab8 100644
--- a/import_export/resources.py
+++ b/import_export/resources.py
@@ -455,18 +455,24 @@ def validate_instance(self, instance, import_validation_errors=None, validate_un
if errors:
raise ValidationError(errors)
- def save_instance(self, instance, using_transactions=True, dry_run=False):
+ def save_instance(self, instance, is_create, using_transactions=True, dry_run=False):
"""
Takes care of saving the object to the database.
Objects can be created in bulk if ``use_bulk`` is enabled.
+
+ :param instance: The instance of the object to be persisted.
+ :param is_create: A boolean flag to indicate whether this is a new object
+ to be created, or an existing object to be updated.
+ :param using_transactions: A flag to indicate whether db transactions are used.
+ :param dry_run: A flag to indicate dry-run mode.
"""
self.before_save_instance(instance, using_transactions, dry_run)
if self._meta.use_bulk:
- if instance.pk:
- self.update_instances.append(instance)
- else:
+ if is_create:
self.create_instances.append(instance)
+ else:
+ self.update_instances.append(instance)
else:
if not using_transactions and dry_run:
# we don't have transactions and we want to do a dry_run
@@ -698,7 +704,7 @@ def import_row(self, row, instance_loader, using_transactions=True, dry_run=Fals
row_result.import_type = RowResult.IMPORT_TYPE_SKIP
else:
self.validate_instance(instance, import_validation_errors)
- self.save_instance(instance, using_transactions, dry_run)
+ self.save_instance(instance, new, using_transactions, dry_run)
self.save_m2m(instance, row, using_transactions, dry_run)
row_result.add_instance_info(instance)
if not skip_diff:
diff --git a/tests/core/migrations/0010_uuidbook.py b/tests/core/migrations/0010_uuidbook.py
new file mode 100644
index 000000000..6d5cba9c2
--- /dev/null
+++ b/tests/core/migrations/0010_uuidbook.py
@@ -0,0 +1,21 @@
+# Generated by Django 2.2.7 on 2021-05-02 07:46
+import uuid
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0009_auto_20211111_0807'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='UUIDBook',
+ fields=[
+ ('id', models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)),
+ ('name', models.CharField(max_length=100, verbose_name='Book name')),
+ ],
+ ),
+ ]
diff --git a/tests/core/models.py b/tests/core/models.py
index 97f5d5c39..c8d1f1cff 100644
--- a/tests/core/models.py
+++ b/tests/core/models.py
@@ -1,5 +1,6 @@
import random
import string
+import uuid
from django.core.exceptions import ValidationError
from django.db import models
@@ -104,3 +105,9 @@ class EBook(Book):
"""Book proxy model to have a separate admin url access and name"""
class Meta:
proxy = True
+
+
+class UUIDBook(models.Model):
+ """A model which uses a UUID pk (issue 1274)"""
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ name = models.CharField('Book name', max_length=100)
\ No newline at end of file
diff --git a/tests/core/tests/test_resources.py b/tests/core/tests/test_resources.py
index 7e33edb0a..782879042 100644
--- a/tests/core/tests/test_resources.py
+++ b/tests/core/tests/test_resources.py
@@ -30,6 +30,7 @@
Person,
Profile,
Role,
+ UUIDBook,
WithDefault,
WithDynamicDefault,
WithFloatField,
@@ -652,8 +653,8 @@ def before_save_instance(self, instance, using_transactions, dry_run):
self.before_save_instance_dry_run = True
else:
self.before_save_instance_dry_run = False
- def save_instance(self, instance, using_transactions=True, dry_run=False):
- super().save_instance(instance, using_transactions, dry_run)
+ def save_instance(self, instance, new, using_transactions=True, dry_run=False):
+ super().save_instance(instance, new, using_transactions, dry_run)
if dry_run:
self.save_instance_dry_run = True
else:
@@ -679,7 +680,7 @@ def after_save_instance(self, instance, using_transactions, dry_run):
@mock.patch("core.models.Book.save")
def test_save_instance_noop(self, mock_book):
book = Book.objects.first()
- self.resource.save_instance(book, using_transactions=False, dry_run=True)
+ self.resource.save_instance(book, is_create=False, using_transactions=False, dry_run=True)
self.assertEqual(0, mock_book.call_count)
@mock.patch("core.models.Book.save")
@@ -1526,10 +1527,10 @@ class Meta:
rows = [('book_name',)] * 10
self.dataset = tablib.Dataset(*rows, headers=['name'])
- def init_update_test_data(self):
- [Book.objects.create(name='book_name') for _ in range(10)]
- self.assertEqual(10, Book.objects.count())
- rows = Book.objects.all().values_list('id', 'name')
+ def init_update_test_data(self, model=Book):
+ [model.objects.create(name='book_name') for _ in range(10)]
+ self.assertEqual(10, model.objects.count())
+ rows = model.objects.all().values_list('id', 'name')
updated_rows = [(r[0], 'UPDATED') for r in rows]
self.dataset = tablib.Dataset(*updated_rows, headers=['id', 'name'])
@@ -1557,6 +1558,25 @@ class Meta:
mock_bulk_create.assert_called_with(mock.ANY, batch_size=5)
self.assertEqual(10, result.total_rows)
+ @mock.patch('core.models.UUIDBook.objects.bulk_create')
+ def test_bulk_create_uuid_model(self, mock_bulk_create):
+ """Test create of a Model which defines uuid not pk (issue #1274)"""
+ class _UUIDBookResource(resources.ModelResource):
+ class Meta:
+ model = UUIDBook
+ use_bulk = True
+ batch_size = 5
+ fields = (
+ 'id',
+ 'name',
+ )
+
+ resource = _UUIDBookResource()
+ result = resource.import_data(self.dataset)
+ self.assertEqual(2, mock_bulk_create.call_count)
+ mock_bulk_create.assert_called_with(mock.ANY, batch_size=5)
+ self.assertEqual(10, result.total_rows)
+
@mock.patch('core.models.Book.objects.bulk_create')
def test_bulk_create_no_batch_size(self, mock_bulk_create):
class _BookResource(resources.ModelResource):
@@ -1865,6 +1885,33 @@ class Meta:
self.assertEqual(e, raised_exc)
+@skipIf(django.VERSION[0] == 2 and django.VERSION[1] < 2, "bulk_update not supported in this version of django")
+class BulkUUIDBookUpdateTest(BulkTest):
+
+ def setUp(self):
+ super().setUp()
+ self.init_update_test_data(model=UUIDBook)
+
+ @mock.patch('core.models.UUIDBook.objects.bulk_update')
+ def test_bulk_update_uuid_model(self, mock_bulk_update):
+ """Test update of a Model which defines uuid not pk (issue #1274)"""
+ class _UUIDBookResource(resources.ModelResource):
+ class Meta:
+ model = UUIDBook
+ use_bulk = True
+ batch_size = 5
+ fields = (
+ 'id',
+ 'name',
+ )
+
+ resource = _UUIDBookResource()
+ result = resource.import_data(self.dataset)
+ self.assertEqual(2, mock_bulk_update.call_count)
+ self.assertEqual(10, result.total_rows)
+ self.assertEqual(10, result.totals["update"])
+
+
class BulkDeleteTest(BulkTest):
class DeleteBookResource(resources.ModelResource):
def for_delete(self, row, instance):
@@ -1981,3 +2028,25 @@ class Meta:
with self.assertRaises(Exception) as raised_exc:
resource.import_data(self.dataset, raise_errors=True)
self.assertEqual(e, raised_exc)
+
+
+class BulkUUIDBookDeleteTest(BulkTest):
+ class DeleteBookResource(resources.ModelResource):
+ def for_delete(self, row, instance):
+ return True
+
+ class Meta:
+ model = UUIDBook
+ use_bulk = True
+ batch_size = 5
+
+ def setUp(self):
+ super().setUp()
+ self.resource = self.DeleteBookResource()
+ self.init_update_test_data(model=UUIDBook)
+
+ def test_bulk_delete_batch_size_of_5(self):
+ self.assertEqual(10, UUIDBook.objects.count())
+ self.resource.import_data(self.dataset)
+ self.assertEqual(0, UUIDBook.objects.count())
+
From cefe1b4f37ce752579c2c7d9a317f6c4aba72cc3 Mon Sep 17 00:00:00 2001
From: Manel Clos
Date: Thu, 15 Apr 2021 18:57:59 +0200
Subject: [PATCH 02/77] use future value of ManyToManyField to check if value
would change
add tests and reference the issue
added some notes to tests
updated changelog
---
docs/changelog.rst | 6 +++
import_export/resources.py | 12 ++++--
tests/core/tests/test_resources.py | 68 ++++++++++++++++++++++++++++++
3 files changed, 83 insertions(+), 3 deletions(-)
diff --git a/docs/changelog.rst b/docs/changelog.rst
index ace32b905..154974fab 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -10,6 +10,12 @@ Breaking changes
This release makes the following changes to the API. You may need to update your implementation to
accommodate these changes.
+- Check value of ManyToManyField in skip_row() (#1271)
+ - This fixes an issue where ManyToMany fields are not checked correctly in `skip_row()`.
+ This means that `skip_row()` now takes `row` as a mandatory arg.
+ If you have overridden `skip_row()` in your own implementation, you will need to add `row`
+ as an arg.
+
- Use 'create' flag instead of instance.pk (#1362)
- ``import_export.resources.save_instance()`` now takes an additional mandatory argument: `is_create`.
If you have over-ridden `save_instance()` in your own code, you will need to add this new argument.
diff --git a/import_export/resources.py b/import_export/resources.py
index 6fb574ab8..29618b8ac 100644
--- a/import_export/resources.py
+++ b/import_export/resources.py
@@ -577,7 +577,7 @@ def for_delete(self, row, instance):
"""
return False
- def skip_row(self, instance, original):
+ def skip_row(self, instance, original, row):
"""
Returns ``True`` if ``row`` importing should be skipped.
@@ -607,7 +607,13 @@ def skip_row(self, instance, original):
try:
# For fields that are models.fields.related.ManyRelatedManager
# we need to compare the results
- if list(field.get_value(instance).all()) != list(field.get_value(original).all()):
+ if isinstance(field.widget, widgets.ManyToManyWidget):
+ # compare with the future value to detect changes
+ instance_value = list(field.clean(row))
+ else:
+ instance_value = list(field.get_value(instance).all())
+
+ if instance_value != list(field.get_value(original).all()):
return False
except AttributeError:
if field.get_value(instance) != field.get_value(original):
@@ -700,7 +706,7 @@ def import_row(self, row, instance_loader, using_transactions=True, dry_run=Fals
# validate_instance(), where they can be combined with model
# instance validation errors if necessary
import_validation_errors = e.update_error_dict(import_validation_errors)
- if self.skip_row(instance, original):
+ if self.skip_row(instance, original, row):
row_result.import_type = RowResult.IMPORT_TYPE_SKIP
else:
self.validate_instance(instance, import_validation_errors)
diff --git a/tests/core/tests/test_resources.py b/tests/core/tests/test_resources.py
index 782879042..0e7748712 100644
--- a/tests/core/tests/test_resources.py
+++ b/tests/core/tests/test_resources.py
@@ -1401,6 +1401,74 @@ def check_value(self, result, export_headers, expected_value):
expected_value)
+class ManyToManyWidgetDiffTest(TestCase):
+ # issue #1270 - ensure ManyToMany fields are correctly checked for
+ # changes when skip_unchanged=True
+ fixtures = ["category", "book"]
+
+ def setUp(self):
+ pass
+
+ def test_many_to_many_widget_create(self):
+ # the book is associated with 0 categories
+ # when we import a book with category 1, the book
+ # should be updated, not skipped
+ book = Book.objects.first()
+ book.categories.clear()
+ dataset_headers = ["id", "name", "categories"]
+ dataset_row = [book.id, book.name, "1"]
+ dataset = tablib.Dataset(headers=dataset_headers)
+ dataset.append(dataset_row)
+
+ book_resource = BookResource()
+ book_resource._meta.skip_unchanged = True
+ self.assertEqual(0, book.categories.count())
+
+ result = book_resource.import_data(dataset, dry_run=False)
+
+ book.refresh_from_db()
+ self.assertEqual(1, book.categories.count())
+ self.assertEqual(result.rows[0].import_type, results.RowResult.IMPORT_TYPE_UPDATE)
+ self.assertEqual(Category.objects.first(), book.categories.first())
+
+ def test_many_to_many_widget_update(self):
+ # the book is associated with 1 category ('Category 2')
+ # when we import a book with category 1, the book
+ # should be updated, not skipped, so that Category 2 is replaced by Category 1
+ book = Book.objects.first()
+ dataset_headers = ["id", "name", "categories"]
+ dataset_row = [book.id, book.name, "1"]
+ dataset = tablib.Dataset(headers=dataset_headers)
+ dataset.append(dataset_row)
+
+ book_resource = BookResource()
+ book_resource._meta.skip_unchanged = True
+ self.assertEqual(1, book.categories.count())
+
+ result = book_resource.import_data(dataset, dry_run=False)
+ self.assertEqual(result.rows[0].import_type, results.RowResult.IMPORT_TYPE_UPDATE)
+ self.assertEqual(1, book.categories.count())
+ self.assertEqual(Category.objects.first(), book.categories.first())
+
+ def test_many_to_many_widget_no_changes(self):
+ # the book is associated with 1 category ('Category 2')
+ # when we import a row with a book with category 1, the book
+ # should be skipped, because there is no change
+ book = Book.objects.first()
+ dataset_headers = ["id", "name", "categories"]
+ dataset_row = [book.id, book.name, book.categories.all()]
+ dataset = tablib.Dataset(headers=dataset_headers)
+ dataset.append(dataset_row)
+
+ book_resource = BookResource()
+ book_resource._meta.skip_unchanged = True
+
+ self.assertEqual(1, book.categories.count())
+ result = book_resource.import_data(dataset, dry_run=False)
+ self.assertEqual(result.rows[0].import_type, results.RowResult.IMPORT_TYPE_SKIP)
+ self.assertEqual(1, book.categories.count())
+
+
@mock.patch("import_export.resources.Diff", spec=True)
class SkipDiffTest(TestCase):
"""
From e671b1c7ca0c6fbdce886f5f124e4ab40271f73f Mon Sep 17 00:00:00 2001
From: matthewhegarty
Date: Fri, 24 Dec 2021 19:01:49 +0000
Subject: [PATCH 03/77] added css dark mode to import.css (#1370)
---
docs/changelog.rst | 9 +++++
import_export/static/import_export/import.css | 34 +++++++++++++++++++
2 files changed, 43 insertions(+)
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 9623a0b5c..f6e46ab6e 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -1,6 +1,15 @@
Changelog
=========
+3.0.0 (unreleased)
+------------------
+
+Enhancements
+############
+
+- Updated import.css to support dark mode (#1370)
+
+
2.7.1 (2021-12-23)
------------------
diff --git a/import_export/static/import_export/import.css b/import_export/static/import_export/import.css
index bb20ba2a1..739311c41 100644
--- a/import_export/static/import_export/import.css
+++ b/import_export/static/import_export/import.css
@@ -79,3 +79,37 @@ table.import-preview tr.update {
font-weight: bold;
font-size: 0.85em;
}
+
+@media (prefers-color-scheme: dark) {
+ table.import-preview tr.skip {
+ background-color: #2d2d2d;
+ }
+
+ table.import-preview tr.new {
+ background-color: #42274d;
+ }
+
+ table.import-preview tr.delete {
+ background-color: #064140;
+ }
+
+ table.import-preview tr.update {
+ background-color: #020230;
+ }
+
+ .validation-error-container {
+ background-color: #003e3e;
+ }
+
+ /*
+ these declarations are necessary to forcibly override the
+ formatting applied by the diff-match-patch python library
+ */
+ table.import-preview td ins {
+ background-color: #190019 !important;
+ }
+
+ table.import-preview td del {
+ background-color: #001919 !important;
+ }
+}
\ No newline at end of file
From 7889bc294f5dd23d76838c09cf7b9b40251e5e38 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Petr=20Dlouh=C3=BD?=
Date: Sat, 12 Dec 2020 09:00:29 +0100
Subject: [PATCH 04/77] add option to have multiple resource classes in
ModelAdmin
---
docs/changelog.rst | 5 +
docs/getting_started.rst | 53 ++++++++-
import_export/admin.py | 21 +++-
import_export/forms.py | 23 +++-
import_export/mixins.py | 90 ++++++++++++--
import_export/resources.py | 6 +
.../templates/admin/import_export/import.html | 11 +-
tests/core/admin.py | 10 +-
tests/core/tests/test_admin_integration.py | 81 ++++++++++++-
tests/core/tests/test_forms.py | 29 +++++
tests/core/tests/test_mixins.py | 111 +++++++++++++++++-
tests/core/tests/test_resources.py | 14 +++
12 files changed, 420 insertions(+), 34 deletions(-)
create mode 100644 tests/core/tests/test_forms.py
diff --git a/docs/changelog.rst b/docs/changelog.rst
index c085b9790..01a82c64b 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -19,6 +19,11 @@ accommodate these changes.
- Use 'create' flag instead of instance.pk (#1362)
- ``import_export.resources.save_instance()`` now takes an additional mandatory argument: `is_create`.
If you have over-ridden `save_instance()` in your own code, you will need to add this new argument.#
+
+- Add support for multiple resources in ModelAdmin. (#1223)
+ - The `*Mixin.resource_class` accepting single resource has been deprecated (will work for few next versions) and
+ the new `*Mixin.resource_classes` accepting subscriptable type (list, tuple, ...) has been added.
+ - Same applies to all of the `get_resource_class`, `get_import_resource_class` and `get_export_resource_class` methods.
Enhancements
############
diff --git a/docs/getting_started.rst b/docs/getting_started.rst
index d95c30dc8..889d85500 100644
--- a/docs/getting_started.rst
+++ b/docs/getting_started.rst
@@ -336,7 +336,7 @@ mixins (:class:`~import_export.admin.ImportMixin`,
from import_export.admin import ImportExportModelAdmin
class BookAdmin(ImportExportModelAdmin):
- resource_class = BookResource
+ resource_classes = [BookResource]
admin.site.register(Book, BookAdmin)
@@ -353,6 +353,13 @@ mixins (:class:`~import_export.admin.ImportMixin`,
A screenshot of the confirm import view.
+.. warning::
+
+ The `resource_class` parameter was deprecated in `django-import-export` 3.0.
+ Assign list or tuple with Resource(s) to `resource_classes` parameter now.
+
+
+
Exporting via admin action
~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -431,7 +438,7 @@ Customize forms::
Customize ``ModelAdmin``::
class CustomBookAdmin(ImportMixin, admin.ModelAdmin):
- resource_class = BookResource
+ resource_classes = [BookResource]
def get_import_form(self):
return CustomImportForm
@@ -460,7 +467,49 @@ Using the above methods it is possible to customize import form initialization
as well as importing customizations.
+.. warning::
+
+ The `resource_class` parameter was deprecated in `django-import-export` 3.0.
+ Assign list or tuple with Resource(s) to `resource_classes` parameter now.
+
+
.. seealso::
:doc:`/api_admin`
available mixins and options.
+
+Using multiple resources
+------------------------
+
+It is possible to set multiple resources both to import and export `ModelAdmin` classes.
+The `ImportMixin`, `ExportMixin`, `ImportExportMixin` and `ImportExportModelAdmin` classes accepts
+subscriptable type (list, tuple, ...) as `resource_classes` parameter.
+The subscriptable could also be returned from one of the
+`get_resource_classes()`, `get_import_resource_classes()`, `get_export_resource_classes()` classes.
+
+If there are multiple resources, the resource chooser appears in import/export admin form.
+The displayed name of the resource can be changed through the `name` parameter of the `Meta` class.
+
+
+Use multiple resources::
+
+ from import_export import resources
+ from core.models import Book
+
+
+ class BookResource(resources.ModelResource):
+
+ class Meta:
+ model = Book
+
+
+ class BookNameResource(resources.ModelResource):
+
+ class Meta:
+ model = Book
+ fields = ['id', 'name']
+ name = "Export/Import only book names"
+
+
+ class CustomBookAdmin(ImportMixin, admin.ModelAdmin):
+ resource_classes = [BookResource, BookNameResource]
diff --git a/import_export/admin.py b/import_export/admin.py
index 1c552b277..bc4939b72 100644
--- a/import_export/admin.py
+++ b/import_export/admin.py
@@ -119,7 +119,7 @@ def process_import(self, request, *args, **kwargs):
def process_dataset(self, dataset, confirm_form, request, *args, **kwargs):
res_kwargs = self.get_import_resource_kwargs(request, form=confirm_form, *args, **kwargs)
- resource = self.get_import_resource_class()(**res_kwargs)
+ resource = self.choose_import_resource_class(confirm_form)(**res_kwargs)
imp_kwargs = self.get_import_data_kwargs(request, form=confirm_form, *args, **kwargs)
return resource.import_data(dataset,
@@ -238,6 +238,7 @@ def import_action(self, request, *args, **kwargs):
form_type = self.get_import_form()
form_kwargs = self.get_form_kwargs(form_type, *args, **kwargs)
form = form_type(import_formats,
+ self.get_import_resource_classes(),
request.POST or None,
request.FILES or None,
**form_kwargs)
@@ -265,7 +266,8 @@ def import_action(self, request, *args, **kwargs):
# prepare kwargs for import data, if needed
res_kwargs = self.get_import_resource_kwargs(request, form=form, *args, **kwargs)
- resource = self.get_import_resource_class()(**res_kwargs)
+ resource = self.choose_import_resource_class(form)(**res_kwargs)
+ resources = [resource]
# prepare additional kwargs for import_data, if needed
imp_kwargs = self.get_import_data_kwargs(request, form=form, *args, **kwargs)
@@ -282,20 +284,25 @@ def import_action(self, request, *args, **kwargs):
'import_file_name': tmp_storage.name,
'original_file_name': import_file.name,
'input_format': form.cleaned_data['input_format'],
+ 'resource': request.POST.get('resource', ''),
}
confirm_form = self.get_confirm_import_form()
initial = self.get_form_kwargs(form=form, **initial)
context['confirm_form'] = confirm_form(initial=initial)
else:
res_kwargs = self.get_import_resource_kwargs(request, form=form, *args, **kwargs)
- resource = self.get_import_resource_class()(**res_kwargs)
+ resource_classes = self.get_import_resource_classes()
+ resources = [resource_class(**res_kwargs) for resource_class in resource_classes]
context.update(self.admin_site.each_context(request))
context['title'] = _("Import")
context['form'] = form
context['opts'] = self.model._meta
- context['fields'] = [f.column_name for f in resource.get_user_visible_fields()]
+ context['fields_list'] = [
+ (resource.get_display_name(), [f.column_name for f in resource.get_user_visible_fields()])
+ for resource in resources
+ ]
request.current_app = self.admin_site.name
return TemplateResponse(request, [self.import_template_name],
@@ -405,14 +412,16 @@ def export_action(self, request, *args, **kwargs):
raise PermissionDenied
formats = self.get_export_formats()
- form = ExportForm(formats, request.POST or None)
+ form = ExportForm(formats, self.get_export_resource_classes(), request.POST or None)
if form.is_valid():
file_format = formats[
int(form.cleaned_data['file_format'])
]()
queryset = self.get_export_queryset(request)
- export_data = self.get_export_data(file_format, queryset, request=request, encoding=self.to_encoding)
+ export_data = self.get_export_data(
+ file_format, queryset, request=request, encoding=self.to_encoding, export_form=form,
+ )
content_type = file_format.get_content_type()
response = HttpResponse(export_data, content_type=content_type)
response['Content-Disposition'] = 'attachment; filename="%s"' % (
diff --git a/import_export/forms.py b/import_export/forms.py
index 76dbaf751..85f7855ab 100644
--- a/import_export/forms.py
+++ b/import_export/forms.py
@@ -5,7 +5,25 @@
from django.utils.translation import gettext_lazy as _
-class ImportForm(forms.Form):
+class ImportExportFormBase(forms.Form):
+ resource = forms.ChoiceField(
+ label=_('Resource'),
+ choices=(),
+ required=False,
+ )
+
+ def __init__(self, resources=None, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ if resources and len(resources) > 1:
+ resource_choices = []
+ for i, resource in enumerate(resources):
+ resource_choices.append((i, resource.get_display_name()))
+ self.fields['resource'].choices = resource_choices
+ else:
+ del self.fields['resource']
+
+
+class ImportForm(ImportExportFormBase):
import_file = forms.FileField(
label=_('File to import')
)
@@ -29,6 +47,7 @@ class ConfirmImportForm(forms.Form):
import_file_name = forms.CharField(widget=forms.HiddenInput())
original_file_name = forms.CharField(widget=forms.HiddenInput())
input_format = forms.CharField(widget=forms.HiddenInput())
+ resource = forms.CharField(widget=forms.HiddenInput(), required=False)
def clean_import_file_name(self):
data = self.cleaned_data['import_file_name']
@@ -36,7 +55,7 @@ def clean_import_file_name(self):
return data
-class ExportForm(forms.Form):
+class ExportForm(ImportExportFormBase):
file_format = forms.ChoiceField(
label=_('Format'),
choices=(),
diff --git a/import_export/mixins.py b/import_export/mixins.py
index 5c2830ee7..c10e2fb24 100644
--- a/import_export/mixins.py
+++ b/import_export/mixins.py
@@ -1,3 +1,5 @@
+import warnings
+
from django.http import HttpResponse
from django.utils.timezone import now
from django.views.generic.edit import FormView
@@ -11,22 +13,65 @@
class BaseImportExportMixin:
formats = base_formats.DEFAULT_FORMATS
resource_class = None
-
- def get_resource_class(self):
- if not self.resource_class:
- return modelresource_factory(self.model)
- return self.resource_class
+ resource_classes = []
+
+ def check_resource_classes(self, resource_classes):
+ if resource_classes and not hasattr(resource_classes, '__getitem__'):
+ raise Exception("The resource_classes field type must be subscriptable (list, tuple, ...)")
+
+ def get_resource_classes(self):
+ """ Return subscriptable type (list, tuple, ...) containing resource classes """
+ if self.resource_classes and self.resource_class:
+ raise Exception("Only one of 'resource_class' and 'resource_classes' can be set")
+ if hasattr(self, 'get_resource_class'):
+ warnings.warn(
+ "The 'get_resource_class()' method has been deprecated. "
+ "Please implement the new 'get_resource_classes()' method",
+ DeprecationWarning,
+ )
+ return self.get_resource_class()
+ if self.resource_class:
+ warnings.warn(
+ "The 'resource_class' field has been deprecated. "
+ "Please implement the new 'resource_classes' field",
+ DeprecationWarning,
+ )
+ if not self.resource_classes and not self.resource_class:
+ return [modelresource_factory(self.model)]
+ if self.resource_classes:
+ return self.resource_classes
+ return [self.resource_class]
def get_resource_kwargs(self, request, *args, **kwargs):
return {}
+ def get_resource_index(self, form):
+ resource_index = 0
+ if form and 'resource' in form.cleaned_data:
+ try:
+ resource_index = int(form.cleaned_data['resource'])
+ except ValueError:
+ pass
+ return resource_index
+
class BaseImportMixin(BaseImportExportMixin):
- def get_import_resource_class(self):
+
+ def get_import_resource_classes(self):
"""
- Returns ResourceClass to use for import.
+ Returns ResourceClass subscriptable (list, tuple, ...) to use for import.
"""
- return self.get_resource_class()
+ if hasattr(self, 'get_import_resource_class'):
+ warnings.warn(
+ "The 'get_import_resource_class()' method has been deprecated. "
+ "Please implement the new 'get_import_resource_classes()' method",
+ DeprecationWarning,
+ )
+ return [self.get_import_resource_class()]
+
+ resource_classes = self.get_resource_classes()
+ self.check_resource_classes(resource_classes)
+ return resource_classes
def get_import_formats(self):
"""
@@ -37,6 +82,10 @@ def get_import_formats(self):
def get_import_resource_kwargs(self, request, *args, **kwargs):
return self.get_resource_kwargs(request, *args, **kwargs)
+ def choose_import_resource_class(self, form):
+ resource_index = self.get_resource_index(form)
+ return self.get_import_resource_classes()[resource_index]
+
class BaseExportMixin(BaseImportExportMixin):
model = None
@@ -47,18 +96,33 @@ def get_export_formats(self):
"""
return [f for f in self.formats if f().can_export()]
- def get_export_resource_class(self):
+ def get_export_resource_classes(self):
"""
- Returns ResourceClass to use for export.
+ Returns ResourceClass subscriptable (list, tuple, ...) to use for export.
"""
- return self.get_resource_class()
+ if hasattr(self, 'get_export_resource_class'):
+ warnings.warn(
+ "The 'get_export_resource_class()' method has been deprecated. "
+ "Please implement the new 'get_export_resource_classes()' method",
+ DeprecationWarning,
+ )
+ return [self.get_export_resource_class()]
+
+ resource_classes = self.get_resource_classes()
+ self.check_resource_classes(resource_classes)
+ return resource_classes
+
+ def choose_export_resource_class(self, form):
+ resource_index = self.get_resource_index(form)
+ return self.get_export_resource_classes()[resource_index]
def get_export_resource_kwargs(self, request, *args, **kwargs):
return self.get_resource_kwargs(request, *args, **kwargs)
def get_data_for_export(self, request, queryset, *args, **kwargs):
- resource_class = self.get_export_resource_class()
- return resource_class(**self.get_export_resource_kwargs(request, *args, **kwargs))\
+ export_form = kwargs.pop('export_form', None)
+ return self.choose_export_resource_class(export_form)\
+ (**self.get_export_resource_kwargs(request, *args, **kwargs))\
.export(queryset, *args, **kwargs)
def get_export_filename(self, file_format):
diff --git a/import_export/resources.py b/import_export/resources.py
index 29618b8ac..f3c051373 100644
--- a/import_export/resources.py
+++ b/import_export/resources.py
@@ -1174,6 +1174,12 @@ def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
finally:
cursor.close()
+ @classmethod
+ def get_display_name(cls):
+ if hasattr(cls._meta, 'name'):
+ return cls._meta.name
+ return cls.__name__
+
def modelresource_factory(model, resource_class=ModelResource):
"""
diff --git a/import_export/templates/admin/import_export/import.html b/import_export/templates/admin/import_export/import.html
index f791c790d..85ebf12f2 100644
--- a/import_export/templates/admin/import_export/import.html
+++ b/import_export/templates/admin/import_export/import.html
@@ -29,7 +29,16 @@
{% trans "This importer will import the following fields: " %}
- {{ fields|join:", " }}
+ {% if fields_list|length <= 1 %}
+ {{ fields_list.0.1|join:", " }}
+ {% else %}
+
+ {% for resource, fields in fields_list %}
+ - {{ resource }}
+ {{ fields|join:", " }}
+ {% endfor %}
+
+ {% endif %}