Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Full clean builtin #141

Merged
merged 1 commit into from
Sep 29, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ Using `Q()` objects instead of querysets allows making queries in
`with_ids` without requiring a subquery on filtered relations, which
can be a big performance win on large tables.

Now, `full_clean` is automatically called upon `save()` of a
`BinderModel`. This should not be a huge breaking change because most
projects "in the wild" are already using the
[django-fullclean](https://github.com/fish-ball/django-fullclean)
plugin. This should reduce the number of checks done while saving
objects. However, it also means that if a non-binder model is saved
it will no longer be validated. If you want this to happen you need
to override `_store` to do some checking.

### Features
- TextFields can now be filtered with the `isnull` qualifier (#134).
- Added a new `unupdatable_fields` property to views which lists
Expand Down
52 changes: 52 additions & 0 deletions binder/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import re
import warnings
from collections import defaultdict
from datetime import date, datetime, time
from contextlib import suppress

Expand Down Expand Up @@ -470,6 +471,57 @@ def annotations(cls):
return getattr(cls, ann_name)


def save(self, *args, **kwargs):
self.full_clean() # Never allow saving invalid models!
return super().save(*args, **kwargs)


def full_clean(self, *args, **kwargs):
# Determine if the field needs an extra nullability check.
# Expects the field object (not the field name)
def field_needs_nullability_check(field):
if isinstance(field, (models.CharField, models.TextField, models.BooleanField)):
if field.blank and not field.null:
return True

return False


validation_errors = defaultdict(list)

try:
res = super().full_clean(*args, **kwargs)
except ValidationError as ve:
if hasattr(ve, 'error_dict'):
for key, value in ve.error_dict.items():
validation_errors[key] += value
elif hasattr(ve, 'error_list'):
for e in ve.error_list:
validation_errors['null'].append(e) # XXX

# Django's standard full_clean() doesn't complain about some
# not-NULL fields being None. This causes save() to explode
# with a django.db.IntegrityError because the column is NOT
# NULL. Tyvm, Django. So we perform an extra NULL check for
# some cases. See #66, T2989, T9646.
for f in self._meta.fields:
if field_needs_nullability_check(f):
# gettattr on a foreignkey foo gets the related model, while foo_id just gets the id.
# We don't need or want the model (nor the DB query), we'll take the id thankyouverymuch.
name = f.name + ('_id' if isinstance(f, models.ForeignKey) else '')

if getattr(self, name) is None and getattr(self, f.name) is None:
validation_errors[f.name].append(ValidationError(
'This field cannot be null.',
code='null',
))

if validation_errors:
raise ValidationError(validation_errors)
else:
return res


def history_obj_post_init(sender, instance, **kwargs):
instance._history = instance.binder_concrete_fields_as_dict(skip_deferred_fields=True)

Expand Down
113 changes: 32 additions & 81 deletions binder/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1111,37 +1111,20 @@ def _sanity_check_meta_results(self, request, response_data):



# Determine if the field needs an extra nullability check.
# Expects the field object (not the field name)
def field_needs_nullability_check(self, field):
if isinstance(field, (models.CharField, models.TextField, models.BooleanField)):
if field.blank and not field.null:
return True

return False



def binder_clean(self, obj, pk=None):
try:
res = obj.full_clean()
except ValidationError as ve:
model_name = self.get_model_view(obj.__class__)._model_name()

raise BinderValidationError({
model_name: {
obj.pk if pk is None else pk: {
f: [
{'code': e.code, 'message': e.messages[0]}
for e in el
]
for f, el in ve.error_dict.items()
}
def binder_validation_error(self, obj, validation_error, pk=None):
model_name = self.get_model_view(obj.__class__)._model_name()

return BinderValidationError({
model_name: {
obj.pk if pk is None else pk: {
f: [
{'code': e.code, 'message': e.messages[0]}
for e in el
]
for f, el in validation_error.error_dict.items()
}
})
else:
return res

}
})


# Deserialize JSON to Django Model objects.
Expand Down Expand Up @@ -1185,39 +1168,15 @@ def store_m2m_field(obj, field, value, request):
except BinderValidationError as e:
validation_errors.append(e)

try:
self.binder_clean(obj, pk=pk)
except BinderValidationError as bve:
validation_errors.append(bve)


# full_clean() doesn't complain about some not-NULL fields being None.
# This causes save() to explode with a django.db.IntegrityError because the
# column is NOT NULL. Tyvm, Django.
# So we perform an extra NULL check for some cases. See #66, T2989, T9646.
for f in obj._meta.fields:
if self.field_needs_nullability_check(f):
# gettattr on a foreignkey foo gets the related model, while foo_id just gets the id.
# We don't need or want the model (nor the DB query), we'll take the id thankyouverymuch.
name = f.name + ('_id' if isinstance(f, models.ForeignKey) else '')

if getattr(obj, name) is None:
e = BinderValidationError({
self._model_name(): {
obj.pk if pk is None else pk: {
f.name: [{
'code': 'null',
'message': 'This field cannot be null.'
}]
}
}
})
validation_errors.append(e)

if validation_errors:
raise sum(validation_errors, None)

obj.save()
try:
obj.save()
except ValidationError as ve:
validation_errors.append(self.binder_validation_error(obj, ve, pk=pk))


for field, value in deferred_m2ms.items():
try:
Expand Down Expand Up @@ -1290,9 +1249,9 @@ def _store_m2m_field(self, obj, field, value, request):
for addobj in obj_field.model.objects.filter(id__in=new_ids - old_ids):
setattr(addobj, obj_field.field.name, obj)
try:
self.binder_clean(addobj)
except BinderValidationError as bve:
validation_errors.append(bve)
addobj.save()
except ValidationError as ve:
validation_errors.append(self.binder_validation_error(addobj, ve))
else:
addobj.save()
elif getattr(obj._meta.model, field).__class__ == models.fields.related.ReverseOneToOneDescriptor:
Expand All @@ -1310,12 +1269,10 @@ def _store_m2m_field(self, obj, field, value, request):
remote_obj = field_descriptor.related.remote_field.model.objects.get(pk=value[0])
setattr(remote_obj, field_descriptor.related.remote_field.name, obj)
try:
self.binder_clean(remote_obj)
except BinderValidationError as bve:
validation_errors.append(bve)
else:
remote_obj.save()
remote_obj.refresh_from_db()
except ValidationError as ve:
validation_errors.append(self.binder_validation_error(remote_obj, ve))
elif any(f.name == field for f in self._get_reverse_relations()):
#### XXX FIXME XXX ugly quick fix for reverse relation + multiput issue
if any(v for v in value if v < 0):
Expand Down Expand Up @@ -1428,6 +1385,10 @@ def _store_field(self, obj, field, value, request, pk=None):
except TypeError:
raise BinderFieldTypeError(self.model.__name__, field)
except ValidationError as ve:
# This would be nice, but this particular validation error
# has no error dict... (TODO FIXME)
# raise self.binder_validation_error(obj, ve, pk=pk)

model_name = self.get_model_view(obj.__class__)._model_name()
raise BinderValidationError({
model_name: {
Expand Down Expand Up @@ -2021,8 +1982,10 @@ def soft_delete(self, obj, undelete, request):
return

obj.deleted = not undelete
self.binder_clean(obj, pk=obj.pk)
obj.save()
try:
obj.save()
except ValidationError as ve:
raise self.binder_validation_error(obj, ve)



Expand Down Expand Up @@ -2172,19 +2135,7 @@ def dispatch_file_field(self, request, pk=None, file_field=None):
return JsonResponse( {"data": {file_field_name: path}} )

except ValidationError as ve:
model_name = self.get_model_view(obj.__class__)._model_name()

raise BinderValidationError({
model_name: {
obj.pk if pk is None else pk: {
f: [
{'code': e.code, 'message': e.messages[0]}
for e in el
]
for f, el in ve.error_dict.items()
}
}
})
raise self.binder_validation_error(obj, ve, pk=pk)

if request.method == 'DELETE':
self._require_model_perm('change', request)
Expand Down
4 changes: 0 additions & 4 deletions tests/filters/test_uuid_field_filtering.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,15 @@ def setUp(self):
self.artis.save()

fabbby = Caretaker(name='fabbby')
fabbby.full_clean()
fabbby.save()

door1 = Gate(zoo=self.gaia, keeper=fabbby, serial_number=None)
door1.full_clean()
door1.save()

door2 = Gate(zoo=self.emmen, keeper=fabbby, serial_number='{2e93ec15-2d68-477d-960f-52779ef6198b}')
door2.full_clean()
door2.save()

door3 = Gate(zoo=self.artis, keeper=fabbby, serial_number='3e93ec15-2d68-477d-960f-52779ef6198b')
door3.full_clean()
door3.save()


Expand Down
2 changes: 1 addition & 1 deletion tests/plugins/test_csvexport.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def temp_imagefile(width, height, format):
return f

def setUp(self):
animal = Animal()
animal = Animal(name='test')
animal.save()

self.pictures = []
Expand Down
2 changes: 1 addition & 1 deletion tests/plugins/test_imageview.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def temp_imagefile(width, height, format):
return f

def _get_picture(self, width, height):
animal = Animal()
animal = Animal(name='test')
animal.save()

file = ImageTest.temp_imagefile(width, height, 'jpeg')
Expand Down
3 changes: 0 additions & 3 deletions tests/test_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ def test_model_with_history_creates_changes_on_creation(self):

def test_model_with_history_creates_changes_on_update_but_only_for_changed_fields(self):
daffy = Animal(name='Daffy Duck')
daffy.full_clean()
daffy.save()

# Model changes outside the HTTP API aren't recorded (should they be?)
Expand Down Expand Up @@ -94,10 +93,8 @@ def test_model_with_history_creates_changes_on_update_but_only_for_changed_field

def test_model_with_related_history_model_creates_changes_on_the_same_changeset(self):
mickey = Caretaker(name='Mickey')
mickey.full_clean()
mickey.save()
pluto = Animal(name='Pluto')
pluto.full_clean()
pluto.save()

# Model changes outside the HTTP API aren't recorded (should they be?)
Expand Down
Loading