diff --git a/binder/exceptions.py b/binder/exceptions.py index d5aec299..75e4e572 100644 --- a/binder/exceptions.py +++ b/binder/exceptions.py @@ -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.' diff --git a/binder/views.py b/binder/views.py index c12c1760..b7414e6d 100644 --- a/binder/views.py +++ b/binder/views.py @@ -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 @@ -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 @@ -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. @@ -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'] @@ -1365,6 +1376,19 @@ 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 ' \ + 'by setting the `allow_standalone_validation` property on this view (see documentation).') + + + # 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) @@ -2073,7 +2097,7 @@ 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). @@ -2081,13 +2105,15 @@ def multi_put(self, request): 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(): @@ -2123,6 +2149,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) @@ -2153,6 +2181,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) @@ -2190,6 +2220,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 diff --git a/docs/api.md b/docs/api.md index 31b0cbf1..51ba97be 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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: @@ -161,6 +161,15 @@ 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. + ### Uploading files To upload a file, you have to add it to the `file_fields` of the `ModelView`: diff --git a/tests/test_model_validation.py b/tests/test_model_validation.py new file mode 100644 index 00000000..59dac762 --- /dev/null +++ b/tests/test_model_validation.py @@ -0,0 +1,228 @@ +from re import I +from tests.testapp.models import contact_person +from tests.testapp.models.contact_person import ContactPerson +from django.test import TestCase, Client + +import json +from binder.json import jsonloads +from django.contrib.auth.models import User +from .testapp.models import Animal, Caretaker, ContactPerson + + +class TestModelValidation(TestCase): + """ + Test the validate-only functionality. + + We check that the validation is executed as normal, but that the models + are not created when the validate query paramter is set to true. + + We check validation for: + - post + - put + - multi-put + - delete + """ + + + def setUp(self): + super().setUp() + u = User(username='testuser', is_active=True, is_superuser=True) + u.set_password('test') + u.save() + self.client = Client() + r = self.client.login(username='testuser', password='test') + self.assertTrue(r) + + # some update payload + self.model_data_with_error = { + 'name': 'very_special_forbidden_contact_person_name', # see `contact_person.py` + } + self.model_data = { + 'name': 'Scooooooby', + } + + + ### helpers ### + + + def assert_validation_error(self, response, person_id=None): + if person_id is None: + person_id = 'null' # for post + + self.assertEqual(response.status_code, 400) + + returned_data = jsonloads(response.content) + + # check that there were validation errors + self.assertEqual(returned_data.get('code'), 'ValidationError') + + # check that the validation error is present + validation_error = returned_data.get('errors').get('contact_person').get(str(person_id)).get('__all__')[0] + self.assertEqual(validation_error.get('code'), 'invalid') + self.assertEqual(validation_error.get('message'), 'Very special validation check that we need in `tests.M2MStoreErrorsTest`.') + + + def assert_multi_put_validation_error(self, response): + self.assertEqual(response.status_code, 400) + + returned_data = jsonloads(response.content) + + # check that there were validation errors + self.assertEqual(returned_data.get('code'), 'ValidationError') + + # check that all (two) the validation errors are present + for error in returned_data.get('errors').get('contact_person').values(): + validation_error = error.get('__all__')[0] + self.assertEqual(validation_error.get('code'), 'invalid') + self.assertEqual(validation_error.get('message'), 'Very special validation check that we need in `tests.M2MStoreErrorsTest`.') + + + ### tests ### + + + def assert_no_validation_error(self, response): + self.assertEqual(response.status_code, 200) + + # check that the validation was successful + returned_data = jsonloads(response.content) + self.assertEqual(returned_data.get('code'), 'SkipSave') + self.assertEqual(returned_data.get('message'), 'No validation errors were encountered.') + + + def test_validate_on_post(self): + self.assertEqual(0, ContactPerson.objects.count()) + + # trigger a validation error + response = self.client.post('/contact_person/?validate=true', data=json.dumps(self.model_data_with_error), content_type='application/json') + self.assert_validation_error(response) + self.assertEqual(0, ContactPerson.objects.count()) + + # now without validation errors + response = self.client.post('/contact_person/?validate=true', data=json.dumps(self.model_data), content_type='application/json') + self.assert_no_validation_error(response) + self.assertEqual(0, ContactPerson.objects.count()) + + # now for real + response = self.client.post('/contact_person/', data=json.dumps(self.model_data), content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual('Scooooooby', ContactPerson.objects.first().name) + + + def test_validate_on_put(self): + person_id = ContactPerson.objects.create(name='Scooby Doo').id + self.assertEqual('Scooby Doo', ContactPerson.objects.first().name) + + # trigger a validation error + response = self.client.put(f'/contact_person/{person_id}/?validate=true', data=json.dumps(self.model_data_with_error), content_type='application/json') + self.assert_validation_error(response, person_id) + self.assertEqual('Scooby Doo', ContactPerson.objects.first().name) + + # now without validation errors + response = self.client.put(f'/contact_person/{person_id}/?validate=true', data=json.dumps(self.model_data), content_type='application/json') + self.assert_no_validation_error(response) + self.assertEqual('Scooby Doo', ContactPerson.objects.first().name) + + # now for real + response = self.client.put(f'/contact_person/{person_id}/', data=json.dumps(self.model_data), content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual('Scooooooby', ContactPerson.objects.first().name) + + + def test_validate_on_multiput(self): + person_1_id = ContactPerson.objects.create(name='Scooby Doo 1').id + person_2_id = ContactPerson.objects.create(name='Scooby Doo 2').id + + multi_put_data = {'data': [ + { + 'id': person_1_id, + 'name': 'New Scooby', + }, + { + 'id': person_2_id, + 'name': 'New Doo' + } + ]} + + multi_put_data_with_error = {'data': [ + { + 'id': person_1_id, + 'name': 'very_special_forbidden_contact_person_name', + }, + { + 'id': person_2_id, + 'name': 'very_special_forbidden_contact_person_name' + } + ]} + + # trigger a validation error + response = self.client.put(f'/contact_person/?validate=true', data=json.dumps(multi_put_data_with_error), content_type='application/json') + self.assert_multi_put_validation_error(response) + self.assertEqual('Scooby Doo 1', ContactPerson.objects.get(id=person_1_id).name) + self.assertEqual('Scooby Doo 2', ContactPerson.objects.get(id=person_2_id).name) + + + # now without validation error + response = self.client.put(f'/contact_person/?validate=true', data=json.dumps(multi_put_data), content_type='application/json') + self.assert_no_validation_error(response) + self.assertEqual('Scooby Doo 1', ContactPerson.objects.get(id=person_1_id).name) + self.assertEqual('Scooby Doo 2', ContactPerson.objects.get(id=person_2_id).name) + + # now for real + response = self.client.put(f'/contact_person/', data=json.dumps(multi_put_data), content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual('New Scooby', ContactPerson.objects.get(id=person_1_id).name) + self.assertEqual('New Doo', ContactPerson.objects.get(id=person_2_id).name) + + + def test_validate_on_delete(self): + '''Check if deletion is cancelled when we only attempt to validate + the delete operation. This test only covers validation of the + on_delete=PROTECT constraint of a fk.''' + + def is_deleted(obj): + '''Whether the obj was soft-deleted, so when the 'deleted' + attribute is not present, or when it is True.''' + + try: + obj.refresh_from_db() + except obj.DoesNotExist: + return True # hard-deleted + return animal.__dict__.get('deleted') or False + + + # animal has a fk to caretaker with on_delete=PROTECT + caretaker = Caretaker.objects.create(name='Connie Care') + animal = Animal.objects.create(name='Pony', caretaker=caretaker) + + + ### with validation error + + response = self.client.delete(f'/caretaker/{caretaker.id}/?validate=true') + # assert validation error + # and check that it was about the PROTECTED constraint + self.assertEqual(response.status_code, 400) + returned_data = jsonloads(response.content) + self.assertEqual(returned_data.get('code'), 'ValidationError') + self.assertEqual(returned_data.get('errors').get('caretaker').get(str(caretaker.id)).get('id')[0].get('code'), 'protected') + + self.assertFalse(is_deleted(caretaker)) + + + ### without validation error + + # now we delete the animal to make sure that deletion is possible + # note that soft-deleting will of course not remove the validation error + animal.delete() + + # now no validation error should be trown + response = self.client.delete(f'/caretaker/{caretaker.id}/?validate=true') + print(response.content) + self.assert_no_validation_error(response) + + self.assertFalse(is_deleted(caretaker)) + + + ### now for real + + response = self.client.delete(f'/caretaker/{caretaker.id}/') + self.assertTrue(is_deleted(caretaker)) diff --git a/tests/testapp/models/animal.py b/tests/testapp/models/animal.py index 85d844e0..49d48987 100644 --- a/tests/testapp/models/animal.py +++ b/tests/testapp/models/animal.py @@ -14,7 +14,7 @@ class Animal(LoadedValuesMixin, BinderModel): name = models.TextField(max_length=64) zoo = models.ForeignKey('Zoo', on_delete=models.CASCADE, related_name='animals', blank=True, null=True) zoo_of_birth = models.ForeignKey('Zoo', on_delete=models.CASCADE, related_name='+', blank=True, null=True) # might've been born outside captivity - caretaker = models.ForeignKey('Caretaker', on_delete=models.PROTECT, related_name='animals', blank=True, null=True) + caretaker = models.ForeignKey('Caretaker', on_delete=models.PROTECT, related_name='animals', blank=True, null=True) # we use the fact that this one is PROTECT in `test_model_validation.py` deleted = models.BooleanField(default=False) # Softdelete def __str__(self): diff --git a/tests/testapp/views/caretaker.py b/tests/testapp/views/caretaker.py index b74006b4..0db69683 100644 --- a/tests/testapp/views/caretaker.py +++ b/tests/testapp/views/caretaker.py @@ -7,3 +7,6 @@ class CaretakerView(ModelView): unwritable_fields = ['last_seen'] unupdatable_fields = ['first_seen'] model = Caretaker + + # see `test_model_validation.py` + allow_standalone_validation = True diff --git a/tests/testapp/views/contact_person.py b/tests/testapp/views/contact_person.py index c1e90c5b..bc2f25fe 100644 --- a/tests/testapp/views/contact_person.py +++ b/tests/testapp/views/contact_person.py @@ -6,3 +6,6 @@ class ContactPersonView(ModelView): model = ContactPerson m2m_fields = ['zoos'] unwritable_fields = ['created_at', 'updated_at'] + + # see `test_model_validation.py` + allow_standalone_validation = True