Skip to content

Commit b43ffba

Browse files
Merge 0921105 into e7b6318
2 parents e7b6318 + 0921105 commit b43ffba

File tree

9 files changed

+271
-106
lines changed

9 files changed

+271
-106
lines changed

docs/changelog.rst

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,17 @@ Breaking changes
1010
This release makes the following changes to the API. You may need to update your implementation to accommodate these changes.
1111

1212
- Check value of ManyToManyField in skip_row() (#1271)
13-
- 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.
13+
- 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.
14+
15+
- Refactor admin import to include encoding param (#1306)
16+
- Admin user interface: If an exception is thrown when attempting to read a file then this error is presented as a form error, instead of being written directly back in the response HTML. If you have any code or process which checks the HTML response for the error (i.e. wrapped in H1 HTML tags) then this will need to be updated to handle the errors which are now returned as form errors.
17+
- This change also refactors the `tmp_storages` interface. If you have made any changes which call the `tmp_storages` interface, then these will need to be updated.
1418

1519
- Use 'create' flag instead of instance.pk (#1362)
16-
- `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.
20+
- ``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.
1721

1822
- Add support for multiple resources in ModelAdmin. (#1223)
19-
2023
- 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.
21-
2224
- Same applies to all of the `get_resource_class`, `get_import_resource_class` and `get_export_resource_class` methods.
2325

2426
- Deprecated `exceptions.py` - this module will be removed in a future release (#1372)
@@ -27,6 +29,7 @@ Enhancements
2729
############
2830

2931
- Updated import.css to support dark mode (#1370)
32+
- Admin site: Refactored reading bytes from temporary storage to address decoding issues (#1306)
3033

3134
Development
3235
###########

docs/getting_started.rst

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,10 +340,14 @@ mixins (:class:`~import_export.admin.ImportMixin`,
340340

341341
admin.site.register(Book, BookAdmin)
342342

343+
.. _change-screen-figure:
344+
343345
.. figure:: _static/images/django-import-export-change.png
344346

345347
A screenshot of the change view with Import and Export buttons.
346348

349+
.. _confirm-import-figure:
350+
347351
.. figure:: _static/images/django-import-export-import.png
348352

349353
A screenshot of the import view.
@@ -478,6 +482,29 @@ as well as importing customizations.
478482
:doc:`/api_admin`
479483
available mixins and options.
480484

485+
Import confirmation
486+
~~~~~~~~~~~~~~~~~~~
487+
488+
Importing in the Admin site is a two step process.
489+
490+
#. Choose the file to import (:ref:`screenshot<change-screen-figure>`).
491+
#. Review changes and confirm import (:ref:`screenshot<confirm-import-figure>`).
492+
493+
To support this, uploaded data is written to temporary storage after step 1, and read
494+
back for final import after step 2.
495+
496+
There are three mechanisms for temporary storage.
497+
498+
#. Temporary file storage on the host server (default). This is suitable for development only.
499+
Use of temporary filesystem storage is not recommended for production sites.
500+
501+
#. The `Django cache <https://docs.djangoproject.com/en/dev/topics/cache/>`_.
502+
503+
#. `Django storage <https://docs.djangoproject.com/en/dev/ref/files/storage/>`_.
504+
505+
To modify which storage mechanism is used, please refer to :ref:`IMPORT_EXPORT_TMP_STORAGE_CLASS`.
506+
507+
481508
Using multiple resources
482509
------------------------
483510

docs/installation.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ of losing an audit trail.
6565
Can be overridden on a ``ModelAdmin`` class inheriting from ``ImportMixin`` by
6666
setting the ``skip_admin_log`` class attribute.
6767

68+
.. _IMPORT_EXPORT_TMP_STORAGE_CLASS:
69+
6870
``IMPORT_EXPORT_TMP_STORAGE_CLASS``
6971
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
7072

import_export/admin.py

Lines changed: 60 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from django.template.response import TemplateResponse
1111
from django.urls import path, reverse
1212
from django.utils.decorators import method_decorator
13-
from django.utils.encoding import force_str
1413
from django.utils.module_loading import import_string
1514
from django.utils.translation import gettext_lazy as _
1615
from django.views.decorators.http import require_POST
@@ -19,7 +18,7 @@
1918
from .mixins import BaseExportMixin, BaseImportMixin
2019
from .results import RowResult
2120
from .signals import post_export, post_import
22-
from .tmp_storages import TempFolderStorage
21+
from .tmp_storages import MediaStorage, TempFolderStorage
2322

2423

2524
class ImportExportMixinBase:
@@ -41,7 +40,7 @@ class ImportMixin(BaseImportMixin, ImportExportMixinBase):
4140
#: template for import view
4241
import_template_name = 'admin/import_export/import.html'
4342
#: import data encoding
44-
from_encoding = "utf-8"
43+
from_encoding = "utf-8-sig"
4544
skip_admin_log = None
4645
# storage class for saving temporary files
4746
tmp_storage_class = None
@@ -103,13 +102,15 @@ def process_import(self, request, *args, **kwargs):
103102
import_formats = self.get_import_formats()
104103
input_format = import_formats[
105104
int(confirm_form.cleaned_data['input_format'])
106-
]()
107-
tmp_storage = self.get_tmp_storage_class()(name=confirm_form.cleaned_data['import_file_name'])
108-
data = tmp_storage.read(input_format.get_read_mode())
109-
if not input_format.is_binary() and self.from_encoding:
110-
data = force_str(data, self.from_encoding)
111-
dataset = input_format.create_dataset(data)
105+
](encoding=self.from_encoding)
106+
encoding = None if input_format.is_binary() else self.from_encoding
107+
tmp_storage = self.get_tmp_storage_class()(
108+
name=confirm_form.cleaned_data['import_file_name'],
109+
encoding=encoding
110+
)
112111

112+
data = tmp_storage.read()
113+
dataset = input_format.create_dataset(data)
113114
result = self.process_dataset(dataset, confirm_form, request, *args, **kwargs)
114115

115116
tmp_storage.remove()
@@ -214,12 +215,22 @@ def get_import_data_kwargs(self, request, *args, **kwargs):
214215
return {}
215216

216217
def write_to_tmp_storage(self, import_file, input_format):
217-
tmp_storage = self.get_tmp_storage_class()()
218+
encoding = None
219+
read_mode = 'rb'
220+
if not input_format.is_binary():
221+
encoding = self.from_encoding
222+
read_mode = 'r'
223+
224+
tmp_storage_cls = self.get_tmp_storage_class()
225+
if tmp_storage_cls == MediaStorage:
226+
# files are always persisted to MediaStorage as binary
227+
read_mode = 'rb'
228+
tmp_storage = tmp_storage_cls(encoding=encoding, read_mode=read_mode)
218229
data = bytes()
219230
for chunk in import_file.chunks():
220231
data += chunk
221232

222-
tmp_storage.save(data, input_format.get_read_mode())
233+
tmp_storage.save(data)
223234
return tmp_storage
224235

225236
def import_action(self, request, *args, **kwargs):
@@ -243,52 +254,56 @@ def import_action(self, request, *args, **kwargs):
243254
request.FILES or None,
244255
**form_kwargs)
245256

257+
resources = list()
246258
if request.POST and form.is_valid():
247259
input_format = import_formats[
248260
int(form.cleaned_data['input_format'])
249261
]()
262+
if not input_format.is_binary():
263+
input_format.encoding = self.from_encoding
264+
250265
import_file = form.cleaned_data['import_file']
251266
# first always write the uploaded file to disk as it may be a
252267
# memory file or else based on settings upload handlers
253268
tmp_storage = self.write_to_tmp_storage(import_file, input_format)
254269

255-
# then read the file, using the proper format-specific mode
256-
# warning, big files may exceed memory
257270
try:
258-
data = tmp_storage.read(input_format.get_read_mode())
259-
if not input_format.is_binary() and self.from_encoding:
260-
data = force_str(data, self.from_encoding)
271+
# then read the file, using the proper format-specific mode
272+
# warning, big files may exceed memory
273+
data = tmp_storage.read()
261274
dataset = input_format.create_dataset(data)
262-
except UnicodeDecodeError as e:
263-
return HttpResponse(_(u"<h1>Imported file has a wrong encoding: %s</h1>" % e))
264275
except Exception as e:
265-
return HttpResponse(_(u"<h1>%s encountered while trying to read file: %s</h1>" % (type(e).__name__, import_file.name)))
266-
267-
# prepare kwargs for import data, if needed
268-
res_kwargs = self.get_import_resource_kwargs(request, form=form, *args, **kwargs)
269-
resource = self.choose_import_resource_class(form)(**res_kwargs)
270-
resources = [resource]
271-
272-
# prepare additional kwargs for import_data, if needed
273-
imp_kwargs = self.get_import_data_kwargs(request, form=form, *args, **kwargs)
274-
result = resource.import_data(dataset, dry_run=True,
275-
raise_errors=False,
276-
file_name=import_file.name,
277-
user=request.user,
278-
**imp_kwargs)
279-
280-
context['result'] = result
281-
282-
if not result.has_errors() and not result.has_validation_errors():
283-
initial = {
284-
'import_file_name': tmp_storage.name,
285-
'original_file_name': import_file.name,
286-
'input_format': form.cleaned_data['input_format'],
287-
'resource': request.POST.get('resource', ''),
288-
}
289-
confirm_form = self.get_confirm_import_form()
290-
initial = self.get_form_kwargs(form=form, **initial)
291-
context['confirm_form'] = confirm_form(initial=initial)
276+
form.add_error('import_file',
277+
_(f"'{type(e).__name__}' encountered while trying to read file. "
278+
"Ensure you have chosen the correct format for the file. "
279+
f"{str(e)}"))
280+
281+
if not form.errors:
282+
# prepare kwargs for import data, if needed
283+
res_kwargs = self.get_import_resource_kwargs(request, form=form, *args, **kwargs)
284+
resource = self.choose_import_resource_class(form)(**res_kwargs)
285+
resources = [resource]
286+
287+
# prepare additional kwargs for import_data, if needed
288+
imp_kwargs = self.get_import_data_kwargs(request, form=form, *args, **kwargs)
289+
result = resource.import_data(dataset, dry_run=True,
290+
raise_errors=False,
291+
file_name=import_file.name,
292+
user=request.user,
293+
**imp_kwargs)
294+
295+
context['result'] = result
296+
297+
if not result.has_errors() and not result.has_validation_errors():
298+
initial = {
299+
'import_file_name': tmp_storage.name,
300+
'original_file_name': import_file.name,
301+
'input_format': form.cleaned_data['input_format'],
302+
'resource': request.POST.get('resource', ''),
303+
}
304+
confirm_form = self.get_confirm_import_form()
305+
initial = self.get_form_kwargs(form=form, **initial)
306+
context['confirm_form'] = confirm_form(initial=initial)
292307
else:
293308
res_kwargs = self.get_import_resource_kwargs(request, form=form, *args, **kwargs)
294309
resource_classes = self.get_import_resource_classes()

import_export/formats/base_formats.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ class TablibFormat(Format):
5656
TABLIB_MODULE = None
5757
CONTENT_TYPE = 'application/octet-stream'
5858

59+
def __init__(self, encoding=None):
60+
self.encoding = encoding
61+
5962
def get_format(self):
6063
"""
6164
Import and returns tablib module.
@@ -96,6 +99,12 @@ def can_export(self):
9699

97100

98101
class TextFormat(TablibFormat):
102+
103+
def create_dataset(self, in_stream, **kwargs):
104+
if isinstance(in_stream, bytes) and self.encoding:
105+
in_stream = in_stream.decode(self.encoding)
106+
return super().create_dataset(in_stream, **kwargs)
107+
99108
def get_read_mode(self):
100109
return 'r'
101110

@@ -107,9 +116,6 @@ class CSV(TextFormat):
107116
TABLIB_MODULE = 'tablib.formats._csv'
108117
CONTENT_TYPE = 'text/csv'
109118

110-
def create_dataset(self, in_stream, **kwargs):
111-
return super().create_dataset(in_stream, **kwargs)
112-
113119

114120
class JSON(TextFormat):
115121
TABLIB_MODULE = 'tablib.formats._json'

import_export/tmp_storages.py

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99

1010
class BaseStorage:
1111

12-
def __init__(self, name=None):
12+
def __init__(self, name=None, read_mode='r', encoding=None):
1313
self.name = name
14+
self.read_mode = read_mode
15+
self.encoding = encoding
1416

15-
def save(self, data, mode='w'):
17+
def save(self, data):
1618
raise NotImplementedError
1719

18-
def read(self, read_mode='r'):
20+
def read(self):
1921
raise NotImplementedError
2022

2123
def remove(self):
@@ -24,20 +26,12 @@ def remove(self):
2426

2527
class TempFolderStorage(BaseStorage):
2628

27-
def open(self, mode='r'):
28-
if self.name:
29-
return open(self.get_full_path(), mode)
30-
else:
31-
tmp_file = tempfile.NamedTemporaryFile(delete=False)
32-
self.name = tmp_file.name
33-
return tmp_file
34-
35-
def save(self, data, mode='w'):
36-
with self.open(mode=mode) as file:
29+
def save(self, data):
30+
with self._open(mode='w') as file:
3731
file.write(data)
3832

39-
def read(self, mode='r'):
40-
with self.open(mode=mode) as file:
33+
def read(self):
34+
with self._open(mode=self.read_mode) as file:
4135
return file.read()
4236

4337
def remove(self):
@@ -49,6 +43,14 @@ def get_full_path(self):
4943
self.name
5044
)
5145

46+
def _open(self, mode='r'):
47+
if self.name:
48+
return open(self.get_full_path(), mode, encoding=self.encoding)
49+
else:
50+
tmp_file = tempfile.NamedTemporaryFile(delete=False)
51+
self.name = tmp_file.name
52+
return tmp_file
53+
5254

5355
class CacheStorage(BaseStorage):
5456
"""
@@ -57,12 +59,12 @@ class CacheStorage(BaseStorage):
5759
CACHE_LIFETIME = 86400
5860
CACHE_PREFIX = 'django-import-export-'
5961

60-
def save(self, data, mode=None):
62+
def save(self, data):
6163
if not self.name:
6264
self.name = uuid4().hex
6365
cache.set(self.CACHE_PREFIX + self.name, data, self.CACHE_LIFETIME)
6466

65-
def read(self, read_mode='r'):
67+
def read(self):
6668
return cache.get(self.CACHE_PREFIX + self.name)
6769

6870
def remove(self):
@@ -72,13 +74,16 @@ def remove(self):
7274
class MediaStorage(BaseStorage):
7375
MEDIA_FOLDER = 'django-import-export'
7476

75-
def save(self, data, mode=None):
77+
def __init__(self, name=None, read_mode='rb', encoding=None):
78+
super().__init__(name, read_mode=read_mode, encoding=encoding)
79+
80+
def save(self, data):
7681
if not self.name:
7782
self.name = uuid4().hex
7883
default_storage.save(self.get_full_path(), ContentFile(data))
7984

80-
def read(self, read_mode='rb'):
81-
with default_storage.open(self.get_full_path(), mode=read_mode) as f:
85+
def read(self):
86+
with default_storage.open(self.get_full_path(), mode=self.read_mode) as f:
8287
return f.read()
8388

8489
def remove(self):
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
id,name,author_email
2+
1,Merci � toi,test@example.com

0 commit comments

Comments
 (0)