feat: Better surfacing of validation errors in UI / optional model

instance validationA (#852)

Fixes: #64, #176, #415, #601
This commit is contained in:
Andy Babic 2018-11-02 11:35:05 +00:00 committed by Bojan Mihelac
parent 5e8d5be9ff
commit bd5ce92d05
9 changed files with 528 additions and 125 deletions

View File

@ -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,

View File

@ -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):

View File

@ -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)

View File

@ -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)

View File

@ -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;
}

View File

@ -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 %}

View File

@ -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):

View File

@ -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'])

View File

@ -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):