Skip to content
Open
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
12 changes: 12 additions & 0 deletions binder/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,3 +235,15 @@ def __add__(self, other):
else:
errors[model] = other.errors[model]
return BinderValidationError(errors)


class BinderSkipSave(BinderException):
"""Used to abort the database transaction when validation was successfull.
Validation is possible when saving (post, put, multi-put) or deleting models."""

http_code = 200
code = 'SkipSave'

def __init__(self):
super().__init__()
self.fields['message'] = 'No validation errors were encountered.'
8 changes: 6 additions & 2 deletions binder/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,8 +403,12 @@ class Meta:
abstract = True
ordering = ['pk']

def save(self, *args, **kwargs):
self.full_clean() # Never allow saving invalid models!
def save(self, *args, only_validate=False, **kwargs):
# A validation model might not require all validation checks as it is not a full model
# _validation_model can be used to skip validation checks that are meant for complete models that are actually being saved
self._validation_model = only_validate # Set the model as a validation model when we only want to validate the model

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


Expand Down
68 changes: 55 additions & 13 deletions binder/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from django.views.generic import View
from django.core.exceptions import ObjectDoesNotExist, FieldError, ValidationError, FieldDoesNotExist
from django.http import HttpResponse, StreamingHttpResponse, HttpResponseForbidden
from django.http.request import RawPostDataException
from django.http.request import RawPostDataException, QueryDict
from django.db import models, connections
from django.db.models import Q, F
from django.db.models.lookups import Transform
Expand All @@ -27,7 +27,11 @@
from django.db.models.fields.reverse_related import ForeignObjectRel


from .exceptions import BinderException, BinderFieldTypeError, BinderFileSizeExceeded, BinderForbidden, BinderImageError, BinderImageSizeExceeded, BinderInvalidField, BinderIsDeleted, BinderIsNotDeleted, BinderMethodNotAllowed, BinderNotAuthenticated, BinderNotFound, BinderReadOnlyFieldError, BinderRequestError, BinderValidationError, BinderFileTypeIncorrect, BinderInvalidURI
from .exceptions import (
BinderException, BinderFieldTypeError, BinderFileSizeExceeded, BinderForbidden, BinderImageError, BinderImageSizeExceeded,
BinderInvalidField, BinderIsDeleted, BinderIsNotDeleted, BinderMethodNotAllowed, BinderNotAuthenticated, BinderNotFound,
BinderReadOnlyFieldError, BinderRequestError, BinderValidationError, BinderFileTypeIncorrect, BinderInvalidURI, BinderSkipSave
)
from . import history
from .orderable_agg import OrderableArrayAgg, GroupConcat, StringAgg
from .models import FieldFilter, BinderModel, ContextAnnotation, OptionalAnnotation, BinderFileField
Expand Down Expand Up @@ -265,6 +269,9 @@ class ModelView(View):
# NOTE: custom _store__foo() methods will still be called for unupdatable fields.
unupdatable_fields = []

# Allow validation without saving.
allow_standalone_validation = False

# Fields to use for ?search=foo. Empty tuple for disabled search.
# NOTE: only string fields and 'id' are supported.
# id is hardcoded to be treated as an integer.
Expand Down Expand Up @@ -371,6 +378,10 @@ def dispatch(self, request, *args, **kwargs):

response = None
try:
# only allow standalone validation if you know what you are doing
if 'validate' in request.GET and request.GET['validate'] == 'true' and not self.allow_standalone_validation:
raise BinderRequestError('Standalone validation not enabled. You must enable this feature explicitly.')

#### START TRANSACTION
with ExitStack() as stack, history.atomic(source='http', user=request.user, uuid=request.request_id):
transaction_dbs = ['default']
Expand Down Expand Up @@ -1365,6 +1376,18 @@ def binder_validation_error(self, obj, validation_error, pk=None):
})



def _abort_when_standalone_validation(self, request):
"""Raise a `BinderSkipSave` exception when this is a validation request."""
if 'validate' in request.GET and request.GET['validate'] == 'true':
if self.allow_standalone_validation:
params = QueryDict(request.body)
raise BinderSkipSave
else:
raise BinderRequestError('Standalone validation not enabled. You must enable this feature explicitly.')



# Deserialize JSON to Django Model objects.
# obj: Model object to update (for PUT), newly created object (for POST)
# values: Python dict of {field name: value} (parsed JSON)
Expand All @@ -1374,6 +1397,9 @@ def _store(self, obj, values, request, ignore_unknown_fields=False, pk=None):
ignored_fields = []
validation_errors = []

# When only validating and not saving we attach a parameter so that we can skip or add validation checks
only_validate = request.GET.get('validate') == 'true' or request.GET.get('validate') == 'True'

if obj.pk is None:
self._require_model_perm('add', request, obj.pk)
else:
Expand Down Expand Up @@ -1411,8 +1437,8 @@ def store_m2m_field(obj, field, value, request):
raise sum(validation_errors, None)

try:
obj.save()
assert(obj.pk is not None) # At this point, the object must have been created.
obj.save(only_validate=only_validate)
assert(obj.pk is not None) # At this point, the object must have been created.
except ValidationError as ve:
validation_errors.append(self.binder_validation_error(obj, ve, pk=pk))

Expand Down Expand Up @@ -1454,6 +1480,9 @@ def store_m2m_field(obj, field, value, request):
def _store_m2m_field(self, obj, field, value, request):
validation_errors = []

# When only validating and not saving we attach a parameter so that we can skip or add validation checks
only_validate = request.GET.get('validate') == 'true' or request.GET.get('validate') == 'True'

# Can't use isinstance() because apparantly ManyToManyDescriptor is a subclass of
# ReverseManyToOneDescriptor. Yes, really.
if getattr(obj._meta.model, field).__class__ == models.fields.related.ReverseManyToOneDescriptor:
Expand Down Expand Up @@ -1496,11 +1525,11 @@ 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:
addobj.save()
addobj.save(only_validate=only_validate)
except ValidationError as ve:
validation_errors.append(self.binder_validation_error(addobj, ve))
else:
addobj.save()
addobj.save(only_validate=only_validate)
elif getattr(obj._meta.model, field).__class__ == models.fields.related.ReverseOneToOneDescriptor:
#### XXX FIXME XXX ugly quick fix for reverse relation + multiput issue
if any(v for v in value if v is not None and v < 0):
Expand All @@ -1516,7 +1545,7 @@ 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:
remote_obj.save()
remote_obj.save(only_validate=only_validate)
remote_obj.refresh_from_db()
except ValidationError as ve:
validation_errors.append(self.binder_validation_error(remote_obj, ve))
Expand Down Expand Up @@ -2073,21 +2102,23 @@ def _multi_put_deletions(self, deletions, new_id_map, request):


def multi_put(self, request):
logger.info('ACTIVATING THE MULTI-PUT!!!1!')
logger.info('ACTIVATING THE MULTI-PUT!!!!!')

# Hack to communicate to _store() that we're not interested in
# the new data (for perf reasons).
request._is_multi_put = True

data, deletions = self._multi_put_parse_request(request)
objects = self._multi_put_collect_objects(data)
objects, overrides = self._multi_put_override_superclass(objects)
objects, overrides = self._multi_put_override_superclass(objects) # model inheritance
objects = self._multi_put_convert_backref_to_forwardref(objects)
dependencies = self._multi_put_calculate_dependencies(objects)
ordered_objects = self._multi_put_order_dependencies(dependencies)
new_id_map = self._multi_put_save_objects(ordered_objects, objects, request)
self._multi_put_id_map_add_overrides(new_id_map, overrides)
new_id_map = self._multi_put_deletions(deletions, new_id_map, request)
new_id_map = self._multi_put_save_objects(ordered_objects, objects, request) # may raise validation errors
self._multi_put_id_map_add_overrides(new_id_map, overrides) # model inheritance
new_id_map = self._multi_put_deletions(deletions, new_id_map, request) # may raise validation errors

self._abort_when_standalone_validation(request)

output = defaultdict(list)
for (model, oid), nid in new_id_map.items():
Expand Down Expand Up @@ -2123,6 +2154,8 @@ def put(self, request, pk=None):

data = self._store(obj, values, request)

self._abort_when_standalone_validation(request)

new = dict(data)
new.pop('_meta', None)

Expand Down Expand Up @@ -2153,6 +2186,8 @@ def post(self, request, pk=None):

data = self._store(self.model(), values, request)

self._abort_when_standalone_validation(request)

new = dict(data)
new.pop('_meta', None)

Expand Down Expand Up @@ -2190,6 +2225,9 @@ def delete(self, request, pk=None, undelete=False, skip_body_check=False):
raise BinderNotFound()

self.delete_obj(obj, undelete, request)

self._abort_when_standalone_validation(request)

logger.info('{}DELETEd {} #{}'.format('UN' if undelete else '', self._model_name(), pk))

return HttpResponse(status=204) # No content
Expand All @@ -2202,6 +2240,10 @@ def delete_obj(self, obj, undelete, request):


def soft_delete(self, obj, undelete, request):

# When only validating and not saving we attach a parameter so that we can skip or add validation checks
only_validate = request.GET.get('validate') == 'true' or request.GET.get('validate') == 'True'

# Not only for soft delets, actually handles all deletions
try:
if obj.deleted and not undelete:
Expand Down Expand Up @@ -2233,7 +2275,7 @@ def soft_delete(self, obj, undelete, request):

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

Expand Down
13 changes: 12 additions & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Ordering is a simple matter of enumerating the fields in the `order_by` query pa
The default sort order is ascending. If you want to sort in descending order, simply prefix the attribute name with a minus sign. This honors the scoping, so `api/animal?order_by=-name,id` will sort by `name` in descending order and by `id` in ascending order.


### Saving a model
### Saving or updating a model

Creating a new model is possible with `POST api/animal/`, and updating a model with `PUT api/animal/`. Both requests accept a JSON body, like this:

Expand Down Expand Up @@ -161,6 +161,17 @@ If this request succeeds, you'll get back a mapping of the fake ids and the real

It is also possible to update existing models with multi PUT. If you use a "real" id instead of a fake one, the model will be updated instead of created.


#### Standalone Validation (without saving models)

Sometimes you want to validate the model that you are going to save without actually saving it. This is useful, for example, when you want to inform the user of validation errors on the frontend, without having to implement the validation logic again. You may check for validation errors by sending a `POST`, `PUT` or `PATCH` request with an additional query parameter `validate`.

Currently this is implemented by raising an `BinderValidateOnly` exception, which makes sure that the atomic database transaction is aborted. Ideally, you would only want to call the validation logic on the models, so only calling validation for fields and validation for model (`clean()`). But for now, we do it this way, at the cost of a performance penalty.

It is important to realize that in this way, the normal `save()` function is called on a model, so it is possible that possible side effects are triggered, when these are implemented directly in `save()`, as opposed to in a signal method, which would be preferable. In other words, we cannot guarantee that the request will be idempotent. Therefore, the validation only feature is disabled by default and must be enabled by setting `allow_standalone_validation=True` on the view.

When a model is being validated and not actually being saved the `_validation_model property` of the binder model is set to True. This allows whitelisting of certain validation checks such as with certain relations that are not included with the validation model.

### Uploading files

To upload a file, you have to add it to the `file_fields` of the `ModelView`:
Expand Down
Loading