feat: Better surfacing of validation errors in UI / optional model
instance validationA (#852) Fixes: #64, #176, #415, #601
This commit is contained in:
parent
5e8d5be9ff
commit
bd5ce92d05
|
@ -281,7 +281,7 @@ class ImportMixin(ImportExportMixinBase):
|
||||||
|
|
||||||
context['result'] = result
|
context['result'] = result
|
||||||
|
|
||||||
if not result.has_errors():
|
if not result.has_errors() and not result.has_validation_errors():
|
||||||
context['confirm_form'] = ConfirmImportForm(initial={
|
context['confirm_form'] = ConfirmImportForm(initial={
|
||||||
'import_file_name': tmp_storage.name,
|
'import_file_name': tmp_storage.name,
|
||||||
'original_file_name': import_file.name,
|
'original_file_name': import_file.name,
|
||||||
|
|
|
@ -65,10 +65,8 @@ class Field(object):
|
||||||
raise KeyError("Column '%s' not found in dataset. Available "
|
raise KeyError("Column '%s' not found in dataset. Available "
|
||||||
"columns are: %s" % (self.column_name, list(data)))
|
"columns are: %s" % (self.column_name, list(data)))
|
||||||
|
|
||||||
try:
|
# If ValueError is raised here, import_obj() will handle it
|
||||||
value = self.widget.clean(value, row=data)
|
value = self.widget.clean(value, row=data)
|
||||||
except ValueError as e:
|
|
||||||
raise ValueError("Column '%s': %s" % (self.column_name, e))
|
|
||||||
|
|
||||||
if value in self.empty_values and self.default != NOT_PROVIDED:
|
if value in self.empty_values and self.default != NOT_PROVIDED:
|
||||||
if callable(self.default):
|
if callable(self.default):
|
||||||
|
|
|
@ -10,7 +10,7 @@ from copy import deepcopy
|
||||||
from diff_match_patch import diff_match_patch
|
from diff_match_patch import diff_match_patch
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
||||||
from django.core.management.color import no_style
|
from django.core.management.color import no_style
|
||||||
from django.db import connections, DEFAULT_DB_ALIAS
|
from django.db import connections, DEFAULT_DB_ALIAS
|
||||||
from django.db.models.fields import FieldDoesNotExist
|
from django.db.models.fields import FieldDoesNotExist
|
||||||
|
@ -114,6 +114,13 @@ class ResourceOptions(object):
|
||||||
Controls if the result reports skipped rows Default value is True
|
Controls if the result reports skipped rows Default value is True
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
clean_model_instances = False
|
||||||
|
"""
|
||||||
|
Controls whether ``instance.full_clean()`` is called during the import
|
||||||
|
process to identify potential validation errors for each (non skipped) row.
|
||||||
|
The default value is False.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class DeclarativeMetaclass(type):
|
class DeclarativeMetaclass(type):
|
||||||
|
|
||||||
|
@ -265,6 +272,31 @@ class Resource(six.with_metaclass(DeclarativeMetaclass)):
|
||||||
else:
|
else:
|
||||||
return (self.init_instance(row), True)
|
return (self.init_instance(row), True)
|
||||||
|
|
||||||
|
def validate_instance(self, instance, import_validation_errors={}, validate_unique=True):
|
||||||
|
"""
|
||||||
|
Takes any validation errors that were raised by
|
||||||
|
:meth:`~import_export.resources.Resource.import_obj`, and combines them
|
||||||
|
with validation errors raised by the instance's ``full_clean()``
|
||||||
|
method. The combined errors are then re-raised as single, multi-field
|
||||||
|
ValidationError.
|
||||||
|
|
||||||
|
If the ``clean_model_instances`` option is False, the instances's
|
||||||
|
``full_clean()`` method is not called, and only the errors raised by
|
||||||
|
``import_obj()`` are re-raised.
|
||||||
|
"""
|
||||||
|
errors = import_validation_errors.copy()
|
||||||
|
if self._meta.clean_model_instances:
|
||||||
|
try:
|
||||||
|
instance.full_clean(
|
||||||
|
exclude=errors.keys(),
|
||||||
|
validate_unique=validate_unique,
|
||||||
|
)
|
||||||
|
except ValidationError as e:
|
||||||
|
errors = e.update_error_dict(errors)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise ValidationError(errors)
|
||||||
|
|
||||||
def save_instance(self, instance, using_transactions=True, dry_run=False):
|
def save_instance(self, instance, using_transactions=True, dry_run=False):
|
||||||
"""
|
"""
|
||||||
Takes care of saving the object to the database.
|
Takes care of saving the object to the database.
|
||||||
|
@ -330,12 +362,21 @@ class Resource(six.with_metaclass(DeclarativeMetaclass)):
|
||||||
def import_obj(self, obj, data, dry_run):
|
def import_obj(self, obj, data, dry_run):
|
||||||
"""
|
"""
|
||||||
Traverses every field in this Resource and calls
|
Traverses every field in this Resource and calls
|
||||||
:meth:`~import_export.resources.Resource.import_field`.
|
:meth:`~import_export.resources.Resource.import_field`. If
|
||||||
"""
|
``import_field()`` results in a ``ValueError`` being raised for
|
||||||
|
one of more fields, those errors are captured and reraised as a single,
|
||||||
|
multi-field ValidationError."""
|
||||||
|
errors = {}
|
||||||
for field in self.get_import_fields():
|
for field in self.get_import_fields():
|
||||||
if isinstance(field.widget, widgets.ManyToManyWidget):
|
if isinstance(field.widget, widgets.ManyToManyWidget):
|
||||||
continue
|
continue
|
||||||
self.import_field(field, obj, data)
|
try:
|
||||||
|
self.import_field(field, obj, data)
|
||||||
|
except ValueError as e:
|
||||||
|
errors[field.attribute] = ValidationError(
|
||||||
|
force_text(e), code="invalid")
|
||||||
|
if errors:
|
||||||
|
raise ValidationError(errors)
|
||||||
|
|
||||||
def save_m2m(self, obj, data, using_transactions, dry_run):
|
def save_m2m(self, obj, data, using_transactions, dry_run):
|
||||||
"""
|
"""
|
||||||
|
@ -463,19 +504,31 @@ class Resource(six.with_metaclass(DeclarativeMetaclass)):
|
||||||
self.delete_instance(instance, using_transactions, dry_run)
|
self.delete_instance(instance, using_transactions, dry_run)
|
||||||
diff.compare_with(self, None, dry_run)
|
diff.compare_with(self, None, dry_run)
|
||||||
else:
|
else:
|
||||||
self.import_obj(instance, row, dry_run)
|
import_validation_errors = {}
|
||||||
|
try:
|
||||||
|
self.import_obj(instance, row, dry_run)
|
||||||
|
except ValidationError as e:
|
||||||
|
# Validation errors from import_obj() are passed on to
|
||||||
|
# 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_result.import_type = RowResult.IMPORT_TYPE_SKIP
|
row_result.import_type = RowResult.IMPORT_TYPE_SKIP
|
||||||
else:
|
else:
|
||||||
|
self.validate_instance(instance, import_validation_errors)
|
||||||
self.save_instance(instance, using_transactions, dry_run)
|
self.save_instance(instance, using_transactions, dry_run)
|
||||||
self.save_m2m(instance, row, using_transactions, dry_run)
|
self.save_m2m(instance, row, using_transactions, dry_run)
|
||||||
|
# Add object info to RowResult for LogEntry
|
||||||
|
row_result.object_id = instance.pk
|
||||||
|
row_result.object_repr = force_text(instance)
|
||||||
diff.compare_with(self, instance, dry_run)
|
diff.compare_with(self, instance, dry_run)
|
||||||
|
|
||||||
row_result.diff = diff.as_html()
|
row_result.diff = diff.as_html()
|
||||||
# Add object info to RowResult for LogEntry
|
|
||||||
if row_result.import_type != RowResult.IMPORT_TYPE_SKIP:
|
|
||||||
row_result.object_id = instance.pk
|
|
||||||
row_result.object_repr = force_text(instance)
|
|
||||||
self.after_import_row(row, row_result, **kwargs)
|
self.after_import_row(row, row_result, **kwargs)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
row_result.import_type = RowResult.IMPORT_TYPE_INVALID
|
||||||
|
row_result.validation_error = e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
row_result.import_type = RowResult.IMPORT_TYPE_ERROR
|
row_result.import_type = RowResult.IMPORT_TYPE_ERROR
|
||||||
# There is no point logging a transaction error for each row
|
# There is no point logging a transaction error for each row
|
||||||
|
@ -549,7 +602,7 @@ class Resource(six.with_metaclass(DeclarativeMetaclass)):
|
||||||
if collect_failed_rows:
|
if collect_failed_rows:
|
||||||
result.add_dataset_headers(dataset.headers)
|
result.add_dataset_headers(dataset.headers)
|
||||||
|
|
||||||
for row in dataset.dict:
|
for i, row in enumerate(dataset.dict, 1):
|
||||||
with atomic_if_using_transaction(using_transactions):
|
with atomic_if_using_transaction(using_transactions):
|
||||||
row_result = self.import_row(
|
row_result = self.import_row(
|
||||||
row,
|
row,
|
||||||
|
@ -559,11 +612,18 @@ class Resource(six.with_metaclass(DeclarativeMetaclass)):
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
result.increment_row_result_total(row_result)
|
result.increment_row_result_total(row_result)
|
||||||
|
|
||||||
if row_result.errors:
|
if row_result.errors:
|
||||||
if collect_failed_rows:
|
if collect_failed_rows:
|
||||||
result.append_failed_row(row, row_result.errors[0])
|
result.append_failed_row(row, row_result.errors[0])
|
||||||
if raise_errors:
|
if raise_errors:
|
||||||
raise row_result.errors[-1].error
|
raise row_result.errors[-1].error
|
||||||
|
elif row_result.validation_error:
|
||||||
|
result.append_invalid_row(i, row, row_result.validation_error)
|
||||||
|
if collect_failed_rows:
|
||||||
|
result.append_failed_row(row, row_result.validation_error)
|
||||||
|
if raise_errors:
|
||||||
|
raise row_result.validation_error
|
||||||
if (row_result.import_type != RowResult.IMPORT_TYPE_SKIP or
|
if (row_result.import_type != RowResult.IMPORT_TYPE_SKIP or
|
||||||
self._meta.report_skipped):
|
self._meta.report_skipped):
|
||||||
result.append_row_result(row_result)
|
result.append_row_result(row_result)
|
||||||
|
|
|
@ -2,6 +2,8 @@ from __future__ import unicode_literals
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
from django.core.exceptions import NON_FIELD_ERRORS
|
||||||
|
|
||||||
from tablib import Dataset
|
from tablib import Dataset
|
||||||
|
|
||||||
|
|
||||||
|
@ -18,11 +20,55 @@ class RowResult(object):
|
||||||
IMPORT_TYPE_DELETE = 'delete'
|
IMPORT_TYPE_DELETE = 'delete'
|
||||||
IMPORT_TYPE_SKIP = 'skip'
|
IMPORT_TYPE_SKIP = 'skip'
|
||||||
IMPORT_TYPE_ERROR = 'error'
|
IMPORT_TYPE_ERROR = 'error'
|
||||||
|
IMPORT_TYPE_INVALID = 'invalid'
|
||||||
|
|
||||||
|
valid_import_types = frozenset([
|
||||||
|
IMPORT_TYPE_NEW,
|
||||||
|
IMPORT_TYPE_UPDATE,
|
||||||
|
IMPORT_TYPE_DELETE,
|
||||||
|
IMPORT_TYPE_SKIP,
|
||||||
|
])
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.errors = []
|
self.errors = []
|
||||||
|
self.validation_error = None
|
||||||
self.diff = None
|
self.diff = None
|
||||||
self.import_type = None
|
self.import_type = None
|
||||||
|
self.raw_values = {}
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidRow(object):
|
||||||
|
"""A row that resulted in one or more ``ValidationError`` being raised during import."""
|
||||||
|
|
||||||
|
def __init__(self, number, validation_error, values):
|
||||||
|
self.number = number
|
||||||
|
self.error = validation_error
|
||||||
|
self.values = values
|
||||||
|
try:
|
||||||
|
self.error_dict = validation_error.message_dict
|
||||||
|
except AttributeError:
|
||||||
|
self.error_dict = {NON_FIELD_ERRORS: validation_error.messages}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def field_specific_errors(self):
|
||||||
|
"""Returns a dictionary of field-specific validation errors for this row."""
|
||||||
|
return {
|
||||||
|
key: value for key, value in self.error_dict.items()
|
||||||
|
if key != NON_FIELD_ERRORS
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def non_field_specific_errors(self):
|
||||||
|
"""Returns a list of non field-specific validation errors for this row."""
|
||||||
|
return self.error_dict.get(NON_FIELD_ERRORS, [])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def error_count(self):
|
||||||
|
"""Returns the total number of validation errors for this row."""
|
||||||
|
count = 0
|
||||||
|
for error_list in self.error_dict.values():
|
||||||
|
count += len(error_list)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
class Result(object):
|
class Result(object):
|
||||||
|
@ -31,14 +77,22 @@ class Result(object):
|
||||||
self.base_errors = []
|
self.base_errors = []
|
||||||
self.diff_headers = []
|
self.diff_headers = []
|
||||||
self.rows = [] # RowResults
|
self.rows = [] # RowResults
|
||||||
|
self.invalid_rows = [] # InvalidRow
|
||||||
self.failed_dataset = Dataset()
|
self.failed_dataset = Dataset()
|
||||||
self.totals = OrderedDict([(RowResult.IMPORT_TYPE_NEW, 0),
|
self.totals = OrderedDict([(RowResult.IMPORT_TYPE_NEW, 0),
|
||||||
(RowResult.IMPORT_TYPE_UPDATE, 0),
|
(RowResult.IMPORT_TYPE_UPDATE, 0),
|
||||||
(RowResult.IMPORT_TYPE_DELETE, 0),
|
(RowResult.IMPORT_TYPE_DELETE, 0),
|
||||||
(RowResult.IMPORT_TYPE_SKIP, 0),
|
(RowResult.IMPORT_TYPE_SKIP, 0),
|
||||||
(RowResult.IMPORT_TYPE_ERROR, 0)])
|
(RowResult.IMPORT_TYPE_ERROR, 0),
|
||||||
|
(RowResult.IMPORT_TYPE_INVALID, 0)])
|
||||||
self.total_rows = 0
|
self.total_rows = 0
|
||||||
|
|
||||||
|
def valid_rows(self):
|
||||||
|
return [
|
||||||
|
r for r in self.rows
|
||||||
|
if r.import_type in RowResult.valid_import_types
|
||||||
|
]
|
||||||
|
|
||||||
def append_row_result(self, row_result):
|
def append_row_result(self, row_result):
|
||||||
self.rows.append(row_result)
|
self.rows.append(row_result)
|
||||||
|
|
||||||
|
@ -50,9 +104,19 @@ class Result(object):
|
||||||
|
|
||||||
def append_failed_row(self, row, error):
|
def append_failed_row(self, row, error):
|
||||||
row_values = [v for (k, v) in row.items()]
|
row_values = [v for (k, v) in row.items()]
|
||||||
row_values.append(str(error.error))
|
try:
|
||||||
|
row_values.append(str(error.error))
|
||||||
|
except AttributeError:
|
||||||
|
row_values.append(str(error))
|
||||||
self.failed_dataset.append(row_values)
|
self.failed_dataset.append(row_values)
|
||||||
|
|
||||||
|
def append_invalid_row(self, number, row, validation_error):
|
||||||
|
self.invalid_rows.append(InvalidRow(
|
||||||
|
number=number,
|
||||||
|
validation_error=validation_error,
|
||||||
|
values=row.values(),
|
||||||
|
))
|
||||||
|
|
||||||
def increment_row_result_total(self, row_result):
|
def increment_row_result_total(self, row_result):
|
||||||
if row_result.import_type:
|
if row_result.import_type:
|
||||||
self.totals[row_result.import_type] += 1
|
self.totals[row_result.import_type] += 1
|
||||||
|
@ -62,7 +126,14 @@ class Result(object):
|
||||||
for i, row in enumerate(self.rows) if row.errors]
|
for i, row in enumerate(self.rows) if row.errors]
|
||||||
|
|
||||||
def has_errors(self):
|
def has_errors(self):
|
||||||
|
"""Returns a boolean indicating whether the import process resulted in
|
||||||
|
any critical (non-validation) errors for this result."""
|
||||||
return bool(self.base_errors or self.row_errors())
|
return bool(self.base_errors or self.row_errors())
|
||||||
|
|
||||||
|
def has_validation_errors(self):
|
||||||
|
"""Returns a boolean indicating whether the import process resulted in
|
||||||
|
any validation errors for this result."""
|
||||||
|
return bool(self.invalid_rows)
|
||||||
|
|
||||||
def __iter__(self):
|
def __iter__(self):
|
||||||
return iter(self.rows)
|
return iter(self.rows)
|
||||||
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
.import-preview .errors {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-error-count {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #e40000;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.9em;
|
||||||
|
position: relative;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: -2px;
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-error-container {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-color: #ffc1c1;
|
||||||
|
padding: 14px 15px 10px;
|
||||||
|
left: 16px;
|
||||||
|
top: 25px;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
width: 200px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-preview td:hover .validation-error-count {
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.import-preview td:hover .validation-error-container {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-error-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-error-list li {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-error-list > li > ul {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-error-list > li > ul > li {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
line-height: 1.28em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-error-field-label {
|
||||||
|
display: block;
|
||||||
|
border-bottom: 1px solid #e40000;
|
||||||
|
color: #e40000;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
|
@ -2,112 +2,170 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load admin_urls %}
|
{% load admin_urls %}
|
||||||
{% load import_export_tags %}
|
{% load import_export_tags %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block extrastyle %}{{ block.super }}<link rel="stylesheet" type="text/css" href="{% static "import_export/import.css" %}" />{% endblock %}
|
||||||
|
|
||||||
{% block breadcrumbs_last %}
|
{% block breadcrumbs_last %}
|
||||||
{% trans "Import" %}
|
{% trans "Import" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if confirm_form %}
|
|
||||||
<form action="{% url opts|admin_urlname:"process_import" %}" method="POST">
|
|
||||||
{% csrf_token %}
|
|
||||||
{{ confirm_form.as_p }}
|
|
||||||
<p>
|
|
||||||
{% trans "Below is a preview of data to be imported. If you are satisfied with the results, click 'Confirm import'" %}
|
|
||||||
</p>
|
|
||||||
<div class="submit-row">
|
|
||||||
<input type="submit" class="default" name="confirm" value="{% trans "Confirm import" %}">
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% else %}
|
{% if confirm_form %}
|
||||||
<form action="" method="post" enctype="multipart/form-data">
|
<form action="{% url opts|admin_urlname:"process_import" %}" method="POST">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
{{ confirm_form.as_p }}
|
||||||
<p>
|
<p>
|
||||||
{% trans "This importer will import the following fields: " %}
|
{% trans "Below is a preview of data to be imported. If you are satisfied with the results, click 'Confirm import'" %}
|
||||||
<code>{{ fields|join:", " }}</code>
|
</p>
|
||||||
</p>
|
<div class="submit-row">
|
||||||
|
<input type="submit" class="default" name="confirm" value="{% trans "Confirm import" %}">
|
||||||
<fieldset class="module aligned">
|
</div>
|
||||||
{% for field in form %}
|
</form>
|
||||||
<div class="form-row">
|
|
||||||
{{ field.errors }}
|
|
||||||
|
|
||||||
{{ field.label_tag }}
|
|
||||||
|
|
||||||
{{ field }}
|
|
||||||
|
|
||||||
{% if field.field.help_text %}
|
|
||||||
<p class="help">{{ field.field.help_text|safe }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div class="submit-row">
|
|
||||||
<input type="submit" class="default" value="{% trans "Submit" %}">
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if result %}
|
|
||||||
|
|
||||||
{% if result.has_errors %}
|
|
||||||
<h2>{% trans "Errors" %}</h2>
|
|
||||||
<ul>
|
|
||||||
{% for error in result.base_errors %}
|
|
||||||
<li>
|
|
||||||
{{ error.error }}
|
|
||||||
<div class="traceback">{{ error.traceback|linebreaks }}</div>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
{% for line, errors in result.row_errors %}
|
|
||||||
{% for error in errors %}
|
|
||||||
<li>
|
|
||||||
{% trans "Line number" %}: {{ line }} - {{ error.error }}
|
|
||||||
<div><code>{{ error.row.values|join:", " }}</code></div>
|
|
||||||
<div class="traceback">{{ error.traceback|linebreaks }}</div>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
|
<form action="" method="post" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
<h2>
|
<p>
|
||||||
{% trans "Preview" %}
|
{% trans "This importer will import the following fields: " %}
|
||||||
</h2>
|
<code>{{ fields|join:", " }}</code>
|
||||||
<table>
|
</p>
|
||||||
<thead>
|
|
||||||
<tr>
|
<fieldset class="module aligned">
|
||||||
<th></th>
|
{% for field in form %}
|
||||||
{% for field in result.diff_headers %}
|
<div class="form-row">
|
||||||
<th>{{ field }}</th>
|
{{ field.errors }}
|
||||||
|
|
||||||
|
{{ field.label_tag }}
|
||||||
|
|
||||||
|
{{ field }}
|
||||||
|
|
||||||
|
{% if field.field.help_text %}
|
||||||
|
<p class="help">{{ field.field.help_text|safe }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</fieldset>
|
||||||
</thead>
|
|
||||||
{% for row in result.rows %}
|
<div class="submit-row">
|
||||||
<tr>
|
<input type="submit" class="default" value="{% trans "Submit" %}">
|
||||||
<td>
|
</div>
|
||||||
{% if row.import_type == 'new' %}
|
</form>
|
||||||
{% trans "New" %}
|
|
||||||
{% elif row.import_type == 'skip' %}
|
|
||||||
{% trans "Skipped" %}
|
|
||||||
{% elif row.import_type == 'delete' %}
|
|
||||||
{% trans "Delete" %}
|
|
||||||
{% elif row.import_type == 'update' %}
|
|
||||||
{% trans "Update" %}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
{% for field in row.diff %}
|
|
||||||
<td>
|
|
||||||
{{ field }}
|
|
||||||
</td>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if result %}
|
||||||
|
|
||||||
|
{% if result.has_errors %}
|
||||||
|
|
||||||
|
<h2>{% trans "Errors" %}</h2>
|
||||||
|
<ul>
|
||||||
|
{% for error in result.base_errors %}
|
||||||
|
<li>
|
||||||
|
{{ error.error }}
|
||||||
|
<div class="traceback">{{ error.traceback|linebreaks }}</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% for line, errors in result.row_errors %}
|
||||||
|
{% for error in errors %}
|
||||||
|
<li>
|
||||||
|
{% trans "Line number" %}: {{ line }} - {{ error.error }}
|
||||||
|
<div><code>{{ error.row.values|join:", " }}</code></div>
|
||||||
|
<div class="traceback">{{ error.traceback|linebreaks }}</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% elif result.has_validation_errors %}
|
||||||
|
|
||||||
|
<h2>{% trans "Some rows failed to validate" %}</h2>
|
||||||
|
|
||||||
|
<p>{% trans "Please correct these errors in your data where possible, then reupload it using the form above." %}</p>
|
||||||
|
|
||||||
|
<table class="import-preview">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Row" %}</th>
|
||||||
|
<th>{% trans "Errors" %}</th>
|
||||||
|
{% for field in result.diff_headers %}
|
||||||
|
<th>{{ field }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in result.invalid_rows %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.number }} </td>
|
||||||
|
<td class="errors">
|
||||||
|
<span class="validation-error-count">{{ row.error_count }}</span>
|
||||||
|
<div class="validation-error-container">
|
||||||
|
<ul class="validation-error-list">
|
||||||
|
{% for field_name, error_list in row.field_specific_errors.items %}
|
||||||
|
<li>
|
||||||
|
<span class="validation-error-field-label">{{ field_name }}</span>
|
||||||
|
<ul>
|
||||||
|
{% for error in error_list %}
|
||||||
|
<li>{{ error }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% if row.non_field_specific_errors %}
|
||||||
|
<li>
|
||||||
|
<span class="validation-error-field-label">{% trans "Non field specific" %}</span>
|
||||||
|
<ul>
|
||||||
|
{% for error in row.non_field_specific_errors %}
|
||||||
|
<li>{{ error }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{% for field in row.values %}
|
||||||
|
<td>{{ field }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<h2>{% trans "Preview" %}</h2>
|
||||||
|
|
||||||
|
<table class="import-preview">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
{% for field in result.diff_headers %}
|
||||||
|
<th>{{ field }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
{% for row in result.valid_rows %}
|
||||||
|
<tr>
|
||||||
|
<td class="import-type">
|
||||||
|
{% if row.import_type == 'new' %}
|
||||||
|
{% trans "New" %}
|
||||||
|
{% elif row.import_type == 'skip' %}
|
||||||
|
{% trans "Skipped" %}
|
||||||
|
{% elif row.import_type == 'delete' %}
|
||||||
|
{% trans "Delete" %}
|
||||||
|
{% elif row.import_type == 'update' %}
|
||||||
|
{% trans "Update" %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% for field in row.diff %}
|
||||||
|
<td>{{ field }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -2,6 +2,7 @@ from __future__ import unicode_literals
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.encoding import python_2_unicode_compatible
|
from django.utils.encoding import python_2_unicode_compatible
|
||||||
|
|
||||||
|
@ -14,6 +15,15 @@ class Author(models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def full_clean(self, exclude=None, validate_unique=True):
|
||||||
|
super(Author, self).full_clean(exclude, validate_unique)
|
||||||
|
if exclude is None:
|
||||||
|
exclude = []
|
||||||
|
else:
|
||||||
|
exclude = list(exclude)
|
||||||
|
if 'name' not in exclude and self.name == '123':
|
||||||
|
raise ValidationError({'name': "'123' is not a valid value"})
|
||||||
|
|
||||||
|
|
||||||
@python_2_unicode_compatible
|
@python_2_unicode_compatible
|
||||||
class Category(models.Model):
|
class Category(models.Model):
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from import_export.results import InvalidRow
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidRowTest(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Create a ValidationEror with a mix of field-specific and non-field-specific errors
|
||||||
|
self.non_field_errors = ValidationError(['Error 1', 'Error 2', 'Error 3'])
|
||||||
|
self.field_errors = ValidationError({
|
||||||
|
'name': ['Error 4', 'Error 5'],
|
||||||
|
'birthday': ['Error 6', 'Error 7'],
|
||||||
|
})
|
||||||
|
combined_error_dict = self.non_field_errors.update_error_dict(
|
||||||
|
self.field_errors.error_dict.copy()
|
||||||
|
)
|
||||||
|
e = ValidationError(combined_error_dict)
|
||||||
|
# Create an InvalidRow instance to use in tests
|
||||||
|
self.obj = InvalidRow(
|
||||||
|
number=1,
|
||||||
|
validation_error=e,
|
||||||
|
values={'name': 'ABC', 'birthday': '123'}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_error_count(self):
|
||||||
|
self.assertEqual(self.obj.error_count, 7)
|
||||||
|
|
||||||
|
def test_non_field_specific_errors(self):
|
||||||
|
result = self.obj.non_field_specific_errors
|
||||||
|
self.assertIsInstance(result, list)
|
||||||
|
self.assertEqual(result, ['Error 1', 'Error 2', 'Error 3'])
|
||||||
|
|
||||||
|
def test_field_specific_errors(self):
|
||||||
|
result = self.obj.field_specific_errors
|
||||||
|
self.assertIsInstance(result, dict)
|
||||||
|
self.assertEqual(len(result), 2)
|
||||||
|
self.assertEqual(result['name'], ['Error 4', 'Error 5'])
|
||||||
|
self.assertEqual(result['birthday'], ['Error 6', 'Error 7'])
|
||||||
|
|
||||||
|
def test_creates_error_dict_from_error_list_if_validation_error_only_has_error_list(self):
|
||||||
|
obj = InvalidRow(
|
||||||
|
number=1,
|
||||||
|
validation_error=self.non_field_errors,
|
||||||
|
values={}
|
||||||
|
)
|
||||||
|
self.assertIsInstance(obj.error_dict, dict)
|
||||||
|
self.assertIn(NON_FIELD_ERRORS, obj.error_dict)
|
||||||
|
self.assertEqual(obj.error_dict[NON_FIELD_ERRORS], ['Error 1', 'Error 2', 'Error 3'])
|
|
@ -1,3 +1,4 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import tablib
|
import tablib
|
||||||
|
@ -13,6 +14,7 @@ from django.db import IntegrityError, DatabaseError
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from django.db.models.fields import FieldDoesNotExist
|
from django.db.models.fields import FieldDoesNotExist
|
||||||
from django.test import TestCase, TransactionTestCase, skipUnlessDBFeature
|
from django.test import TestCase, TransactionTestCase, skipUnlessDBFeature
|
||||||
|
from django.utils import six
|
||||||
from django.utils.html import strip_tags
|
from django.utils.html import strip_tags
|
||||||
from django.utils.encoding import force_text
|
from django.utils.encoding import force_text
|
||||||
|
|
||||||
|
@ -159,6 +161,29 @@ class WithDefaultResource(resources.ModelResource):
|
||||||
fields = ('name',)
|
fields = ('name',)
|
||||||
|
|
||||||
|
|
||||||
|
class HarshRussianWidget(widgets.CharWidget):
|
||||||
|
def clean(self, value, row=None, *args, **kwargs):
|
||||||
|
raise ValueError("Ова вриједност је страшна!")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorResourceWithCustomWidget(resources.ModelResource):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Author
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def widget_from_django_field(cls, f, default=widgets.Widget):
|
||||||
|
if f.name == 'name':
|
||||||
|
return HarshRussianWidget
|
||||||
|
result = default
|
||||||
|
internal_type = f.get_internal_type() if callable(getattr(f, "get_internal_type", None)) else ""
|
||||||
|
if internal_type in cls.WIDGETS_MAP:
|
||||||
|
result = cls.WIDGETS_MAP[internal_type]
|
||||||
|
if isinstance(result, six.string_types):
|
||||||
|
result = getattr(cls, result)(f)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class ModelResourceTest(TestCase):
|
class ModelResourceTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.resource = BookResource()
|
self.resource = BookResource()
|
||||||
|
@ -288,23 +313,87 @@ class ModelResourceTest(TestCase):
|
||||||
self.assertEqual(instance.author_email, 'test@example.com')
|
self.assertEqual(instance.author_email, 'test@example.com')
|
||||||
self.assertEqual(instance.price, Decimal("10.25"))
|
self.assertEqual(instance.price, Decimal("10.25"))
|
||||||
|
|
||||||
def test_import_data_value_error_includes_field_name(self):
|
def test_import_data_raises_field_specific_validation_errors(self):
|
||||||
class AuthorResource(resources.ModelResource):
|
|
||||||
class Meta:
|
|
||||||
model = Author
|
|
||||||
|
|
||||||
resource = AuthorResource()
|
resource = AuthorResource()
|
||||||
dataset = tablib.Dataset(headers=['id', 'name', 'birthday'])
|
dataset = tablib.Dataset(headers=['id', 'name', 'birthday'])
|
||||||
dataset.append(['', 'A.A.Milne', '1882test-01-18'])
|
dataset.append(['', 'A.A.Milne', '1882test-01-18'])
|
||||||
|
|
||||||
result = resource.import_data(dataset, raise_errors=False)
|
result = resource.import_data(dataset, raise_errors=False)
|
||||||
|
|
||||||
self.assertTrue(result.has_errors())
|
self.assertTrue(result.has_validation_errors())
|
||||||
self.assertTrue(result.rows[0].errors)
|
self.assertIs(result.rows[0].import_type, results.RowResult.IMPORT_TYPE_INVALID)
|
||||||
msg = ("Column 'birthday': Enter a valid date/time.")
|
self.assertIn('birthday', result.invalid_rows[0].field_specific_errors)
|
||||||
actual = result.rows[0].errors[0].error
|
|
||||||
self.assertIsInstance(actual, ValueError)
|
def test_import_data_handles_widget_valueerrors_with_unicode_messages(self):
|
||||||
self.assertEqual(msg, str(actual))
|
resource = AuthorResourceWithCustomWidget()
|
||||||
|
dataset = tablib.Dataset(headers=['id', 'name', 'birthday'])
|
||||||
|
dataset.append(['', 'A.A.Milne', '1882-01-18'])
|
||||||
|
|
||||||
|
result = resource.import_data(dataset, raise_errors=False)
|
||||||
|
|
||||||
|
self.assertTrue(result.has_validation_errors())
|
||||||
|
self.assertIs(result.rows[0].import_type, results.RowResult.IMPORT_TYPE_INVALID)
|
||||||
|
self.assertEqual(
|
||||||
|
result.invalid_rows[0].field_specific_errors['name'],
|
||||||
|
["Ова вриједност је страшна!"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_model_validation_errors_not_raised_when_clean_model_instances_is_false(self):
|
||||||
|
|
||||||
|
class TestResource(resources.ModelResource):
|
||||||
|
class Meta:
|
||||||
|
model = Author
|
||||||
|
clean_model_instances = False
|
||||||
|
|
||||||
|
resource = TestResource()
|
||||||
|
dataset = tablib.Dataset(headers=['id', 'name'])
|
||||||
|
dataset.append(['', '123'])
|
||||||
|
|
||||||
|
result = resource.import_data(dataset, raise_errors=False)
|
||||||
|
self.assertFalse(result.has_validation_errors())
|
||||||
|
self.assertEqual(len(result.invalid_rows), 0)
|
||||||
|
|
||||||
|
def test_model_validation_errors_raised_when_clean_model_instances_is_true(self):
|
||||||
|
|
||||||
|
class TestResource(resources.ModelResource):
|
||||||
|
class Meta:
|
||||||
|
model = Author
|
||||||
|
clean_model_instances = True
|
||||||
|
|
||||||
|
resource = TestResource()
|
||||||
|
dataset = tablib.Dataset(headers=['id', 'name'])
|
||||||
|
dataset.append(['', '123'])
|
||||||
|
|
||||||
|
result = resource.import_data(dataset, raise_errors=False)
|
||||||
|
self.assertTrue(result.has_validation_errors())
|
||||||
|
self.assertEqual(result.invalid_rows[0].error_count, 1)
|
||||||
|
self.assertEqual(
|
||||||
|
result.invalid_rows[0].field_specific_errors,
|
||||||
|
{'name': ["'123' is not a valid value"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_known_invalid_fields_are_excluded_from_model_instance_cleaning(self):
|
||||||
|
|
||||||
|
# The custom widget on the parent class should complain about
|
||||||
|
# 'name' first, preventing Author.full_clean() from raising the
|
||||||
|
# error as it does in the previous test
|
||||||
|
|
||||||
|
class TestResource(AuthorResourceWithCustomWidget):
|
||||||
|
class Meta:
|
||||||
|
model = Author
|
||||||
|
clean_model_instances = True
|
||||||
|
|
||||||
|
resource = TestResource()
|
||||||
|
dataset = tablib.Dataset(headers=['id', 'name'])
|
||||||
|
dataset.append(['', '123'])
|
||||||
|
|
||||||
|
result = resource.import_data(dataset, raise_errors=False)
|
||||||
|
self.assertTrue(result.has_validation_errors())
|
||||||
|
self.assertEqual(result.invalid_rows[0].error_count, 1)
|
||||||
|
self.assertEqual(
|
||||||
|
result.invalid_rows[0].field_specific_errors,
|
||||||
|
{'name': ["Ова вриједност је страшна!"]}
|
||||||
|
)
|
||||||
|
|
||||||
def test_import_data_error_saving_model(self):
|
def test_import_data_error_saving_model(self):
|
||||||
row = list(self.dataset.pop())
|
row = list(self.dataset.pop())
|
||||||
|
@ -317,8 +406,7 @@ class ModelResourceTest(TestCase):
|
||||||
self.assertTrue(result.rows[0].errors)
|
self.assertTrue(result.rows[0].errors)
|
||||||
actual = result.rows[0].errors[0].error
|
actual = result.rows[0].errors[0].error
|
||||||
self.assertIsInstance(actual, ValueError)
|
self.assertIsInstance(actual, ValueError)
|
||||||
self.assertIn("Column 'id': could not convert string to float",
|
self.assertIn("could not convert string to float", str(actual))
|
||||||
str(actual))
|
|
||||||
|
|
||||||
def test_import_data_delete(self):
|
def test_import_data_delete(self):
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue