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
|
||||
|
||||
if not result.has_errors():
|
||||
if not result.has_errors() and not result.has_validation_errors():
|
||||
context['confirm_form'] = ConfirmImportForm(initial={
|
||||
'import_file_name': tmp_storage.name,
|
||||
'original_file_name': import_file.name,
|
||||
|
|
|
@ -65,10 +65,8 @@ class Field(object):
|
|||
raise KeyError("Column '%s' not found in dataset. Available "
|
||||
"columns are: %s" % (self.column_name, list(data)))
|
||||
|
||||
try:
|
||||
value = self.widget.clean(value, row=data)
|
||||
except ValueError as e:
|
||||
raise ValueError("Column '%s': %s" % (self.column_name, e))
|
||||
# If ValueError is raised here, import_obj() will handle it
|
||||
value = self.widget.clean(value, row=data)
|
||||
|
||||
if value in self.empty_values and self.default != NOT_PROVIDED:
|
||||
if callable(self.default):
|
||||
|
|
|
@ -10,7 +10,7 @@ from copy import deepcopy
|
|||
from diff_match_patch import diff_match_patch
|
||||
|
||||
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.db import connections, DEFAULT_DB_ALIAS
|
||||
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
|
||||
"""
|
||||
|
||||
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):
|
||||
|
||||
|
@ -265,6 +272,31 @@ class Resource(six.with_metaclass(DeclarativeMetaclass)):
|
|||
else:
|
||||
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):
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
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():
|
||||
if isinstance(field.widget, widgets.ManyToManyWidget):
|
||||
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):
|
||||
"""
|
||||
|
@ -463,19 +504,31 @@ class Resource(six.with_metaclass(DeclarativeMetaclass)):
|
|||
self.delete_instance(instance, using_transactions, dry_run)
|
||||
diff.compare_with(self, None, dry_run)
|
||||
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):
|
||||
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_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)
|
||||
|
||||
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)
|
||||
|
||||
except ValidationError as e:
|
||||
row_result.import_type = RowResult.IMPORT_TYPE_INVALID
|
||||
row_result.validation_error = e
|
||||
except Exception as e:
|
||||
row_result.import_type = RowResult.IMPORT_TYPE_ERROR
|
||||
# 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:
|
||||
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):
|
||||
row_result = self.import_row(
|
||||
row,
|
||||
|
@ -559,11 +612,18 @@ class Resource(six.with_metaclass(DeclarativeMetaclass)):
|
|||
**kwargs
|
||||
)
|
||||
result.increment_row_result_total(row_result)
|
||||
|
||||
if row_result.errors:
|
||||
if collect_failed_rows:
|
||||
result.append_failed_row(row, row_result.errors[0])
|
||||
if raise_errors:
|
||||
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
|
||||
self._meta.report_skipped):
|
||||
result.append_row_result(row_result)
|
||||
|
|
|
@ -2,6 +2,8 @@ from __future__ import unicode_literals
|
|||
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.core.exceptions import NON_FIELD_ERRORS
|
||||
|
||||
from tablib import Dataset
|
||||
|
||||
|
||||
|
@ -18,11 +20,55 @@ class RowResult(object):
|
|||
IMPORT_TYPE_DELETE = 'delete'
|
||||
IMPORT_TYPE_SKIP = 'skip'
|
||||
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):
|
||||
self.errors = []
|
||||
self.validation_error = None
|
||||
self.diff = 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):
|
||||
|
@ -31,14 +77,22 @@ class Result(object):
|
|||
self.base_errors = []
|
||||
self.diff_headers = []
|
||||
self.rows = [] # RowResults
|
||||
self.invalid_rows = [] # InvalidRow
|
||||
self.failed_dataset = Dataset()
|
||||
self.totals = OrderedDict([(RowResult.IMPORT_TYPE_NEW, 0),
|
||||
(RowResult.IMPORT_TYPE_UPDATE, 0),
|
||||
(RowResult.IMPORT_TYPE_DELETE, 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
|
||||
|
||||
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):
|
||||
self.rows.append(row_result)
|
||||
|
||||
|
@ -50,9 +104,19 @@ class Result(object):
|
|||
|
||||
def append_failed_row(self, row, error):
|
||||
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)
|
||||
|
||||
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):
|
||||
if row_result.import_type:
|
||||
self.totals[row_result.import_type] += 1
|
||||
|
@ -62,7 +126,14 @@ class Result(object):
|
|||
for i, row in enumerate(self.rows) if row.errors]
|
||||
|
||||
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())
|
||||
|
||||
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):
|
||||
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 admin_urls %}
|
||||
{% 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 %}
|
||||
{% trans "Import" %}
|
||||
{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
<form action="" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
<p>
|
||||
{% trans "This importer will import the following fields: " %}
|
||||
<code>{{ fields|join:", " }}</code>
|
||||
</p>
|
||||
|
||||
<fieldset class="module aligned">
|
||||
{% for field in 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>
|
||||
{% 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 %}
|
||||
<form action="" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
<h2>
|
||||
{% trans "Preview" %}
|
||||
</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
{% for field in result.diff_headers %}
|
||||
<th>{{ field }}</th>
|
||||
<p>
|
||||
{% trans "This importer will import the following fields: " %}
|
||||
<code>{{ fields|join:", " }}</code>
|
||||
</p>
|
||||
|
||||
<fieldset class="module aligned">
|
||||
{% for field in 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 %}
|
||||
</tr>
|
||||
</thead>
|
||||
{% for row in result.rows %}
|
||||
<tr>
|
||||
<td>
|
||||
{% 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>
|
||||
</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>
|
||||
|
||||
{% 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 %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -2,6 +2,7 @@ from __future__ import unicode_literals
|
|||
import random
|
||||
import string
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
|
||||
|
@ -14,6 +15,15 @@ class Author(models.Model):
|
|||
def __str__(self):
|
||||
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
|
||||
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
|
||||
|
||||
import tablib
|
||||
|
@ -13,6 +14,7 @@ from django.db import IntegrityError, DatabaseError
|
|||
from django.db.models import Count
|
||||
from django.db.models.fields import FieldDoesNotExist
|
||||
from django.test import TestCase, TransactionTestCase, skipUnlessDBFeature
|
||||
from django.utils import six
|
||||
from django.utils.html import strip_tags
|
||||
from django.utils.encoding import force_text
|
||||
|
||||
|
@ -159,6 +161,29 @@ class WithDefaultResource(resources.ModelResource):
|
|||
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):
|
||||
def setUp(self):
|
||||
self.resource = BookResource()
|
||||
|
@ -288,23 +313,87 @@ class ModelResourceTest(TestCase):
|
|||
self.assertEqual(instance.author_email, 'test@example.com')
|
||||
self.assertEqual(instance.price, Decimal("10.25"))
|
||||
|
||||
def test_import_data_value_error_includes_field_name(self):
|
||||
class AuthorResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = Author
|
||||
|
||||
def test_import_data_raises_field_specific_validation_errors(self):
|
||||
resource = AuthorResource()
|
||||
dataset = tablib.Dataset(headers=['id', 'name', 'birthday'])
|
||||
dataset.append(['', 'A.A.Milne', '1882test-01-18'])
|
||||
|
||||
result = resource.import_data(dataset, raise_errors=False)
|
||||
|
||||
self.assertTrue(result.has_errors())
|
||||
self.assertTrue(result.rows[0].errors)
|
||||
msg = ("Column 'birthday': Enter a valid date/time.")
|
||||
actual = result.rows[0].errors[0].error
|
||||
self.assertIsInstance(actual, ValueError)
|
||||
self.assertEqual(msg, str(actual))
|
||||
self.assertTrue(result.has_validation_errors())
|
||||
self.assertIs(result.rows[0].import_type, results.RowResult.IMPORT_TYPE_INVALID)
|
||||
self.assertIn('birthday', result.invalid_rows[0].field_specific_errors)
|
||||
|
||||
def test_import_data_handles_widget_valueerrors_with_unicode_messages(self):
|
||||
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):
|
||||
row = list(self.dataset.pop())
|
||||
|
@ -317,8 +406,7 @@ class ModelResourceTest(TestCase):
|
|||
self.assertTrue(result.rows[0].errors)
|
||||
actual = result.rows[0].errors[0].error
|
||||
self.assertIsInstance(actual, ValueError)
|
||||
self.assertIn("Column 'id': could not convert string to float",
|
||||
str(actual))
|
||||
self.assertIn("could not convert string to float", str(actual))
|
||||
|
||||
def test_import_data_delete(self):
|
||||
|
||||
|
|
Loading…
Reference in New Issue