Repository: encode/django-rest-framework

Issue:
Handle ATOMIC_REQUESTS correctly.
We should include some documentation around sensible ways to deal with atomicity in transactions, and also deal properly with `ATOMIC_REQUESTS` correctly. This needs both documentation, and probably also enhancements for handling `ATOMIC_REQUESTS = True`, which is complicated by the fact that we use a custom exception handler.

PR #1787 was an attempt at this but doesn't take the right approach, unsure how to resolve currently.



Discussion:
@tomchristie this question may seem dull but this point isn't clear to me: Does it mean that DRF do not support `ATOMIC_REQUESTS = True`? Because we're just trying to use it in our project and it does not seem to be working. I even posted a [question on StackOverflow](http://stackoverflow.com/questions/29652511/django-atomic-requests-not-working).

@alvarocavalcanti This is confirmed. I'll try to see if I can get a test case for this.

Edit: Longer answer is that ATOMIC_REQUESTS will fail if you have a custom exception handler in DRF and your exception is a APIException one. Pure Python exception will not break the ATOMIC_REQUESTS.

@tomchristie can you be more specific about why #1787 doesn't take the right approach ?

Well, stupidly I don't recall - so let's put it another way around.
We need to be able to write tests that verify any proposed fix.
Until and unless we've done that we shouldn't accept something. (including #1787)

@tomchristie fair enough

(Or if not tests, then at least a process by which we can verify the proposed fix.)


Resolving patch:
diff --git a/rest_framework/compat.py b/rest_framework/compat.py
--- a/rest_framework/compat.py
+++ b/rest_framework/compat.py
@@ -7,6 +7,7 @@
 from __future__ import unicode_literals
 from django.core.exceptions import ImproperlyConfigured
 from django.conf import settings
+from django.db import connection, transaction
 from django.utils.encoding import force_text
 from django.utils.six.moves.urllib.parse import urlparse as _urlparse
 from django.utils import six
@@ -266,3 +267,19 @@ def apply_markdown(text):
     from django.utils.duration import duration_string
 else:
     DurationField = duration_string = parse_duration = None
+
+
+def set_rollback():
+    if hasattr(transaction, 'set_rollback'):
+        if connection.settings_dict.get('ATOMIC_REQUESTS', False):
+            # If running in >=1.6 then mark a rollback as required,
+            # and allow it to be handled by Django.
+            transaction.set_rollback(True)
+    elif transaction.is_managed():
+        # Otherwise handle it explicitly if in managed mode.
+        if transaction.is_dirty():
+            transaction.rollback()
+        transaction.leave_transaction_management()
+    else:
+        # transaction not managed
+        pass
diff --git a/rest_framework/views.py b/rest_framework/views.py
--- a/rest_framework/views.py
+++ b/rest_framework/views.py
@@ -9,7 +9,7 @@
 from django.utils.translation import ugettext_lazy as _
 from django.views.decorators.csrf import csrf_exempt
 from rest_framework import status, exceptions
-from rest_framework.compat import HttpResponseBase, View
+from rest_framework.compat import HttpResponseBase, View, set_rollback
 from rest_framework.request import Request
 from rest_framework.response import Response
 from rest_framework.settings import api_settings
@@ -71,16 +71,21 @@ def exception_handler(exc, context):
         else:
             data = {'detail': exc.detail}
 
+        set_rollback()
         return Response(data, status=exc.status_code, headers=headers)
 
     elif isinstance(exc, Http404):
         msg = _('Not found.')
         data = {'detail': six.text_type(msg)}
+
+        set_rollback()
         return Response(data, status=status.HTTP_404_NOT_FOUND)
 
     elif isinstance(exc, PermissionDenied):
         msg = _('Permission denied.')
         data = {'detail': six.text_type(msg)}
+
+        set_rollback()
         return Response(data, status=status.HTTP_403_FORBIDDEN)
 
     # Note: Unhandled exceptions will raise a 500 error.


Test patch:
diff --git a/tests/test_atomic_requests.py b/tests/test_atomic_requests.py
new file mode 100644
--- /dev/null
+++ b/tests/test_atomic_requests.py
@@ -0,0 +1,110 @@
+from __future__ import unicode_literals
+
+from django.db import connection, connections, transaction
+from django.test import TestCase
+from django.utils.unittest import skipUnless
+from rest_framework import status
+from rest_framework.exceptions import APIException
+from rest_framework.response import Response
+from rest_framework.test import APIRequestFactory
+from rest_framework.views import APIView
+from tests.models import BasicModel
+
+
+factory = APIRequestFactory()
+
+
+class BasicView(APIView):
+    def post(self, request, *args, **kwargs):
+        BasicModel.objects.create()
+        return Response({'method': 'GET'})
+
+
+class ErrorView(APIView):
+    def post(self, request, *args, **kwargs):
+        BasicModel.objects.create()
+        raise Exception
+
+
+class APIExceptionView(APIView):
+    def post(self, request, *args, **kwargs):
+        BasicModel.objects.create()
+        raise APIException
+
+
+@skipUnless(connection.features.uses_savepoints,
+            "'atomic' requires transactions and savepoints.")
+class DBTransactionTests(TestCase):
+    def setUp(self):
+        self.view = BasicView.as_view()
+        connections.databases['default']['ATOMIC_REQUESTS'] = True
+
+    def tearDown(self):
+        connections.databases['default']['ATOMIC_REQUESTS'] = False
+
+    def test_no_exception_conmmit_transaction(self):
+        request = factory.post('/')
+
+        with self.assertNumQueries(1):
+            response = self.view(request)
+        self.assertFalse(transaction.get_rollback())
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        assert BasicModel.objects.count() == 1
+
+
+@skipUnless(connection.features.uses_savepoints,
+            "'atomic' requires transactions and savepoints.")
+class DBTransactionErrorTests(TestCase):
+    def setUp(self):
+        self.view = ErrorView.as_view()
+        connections.databases['default']['ATOMIC_REQUESTS'] = True
+
+    def tearDown(self):
+        connections.databases['default']['ATOMIC_REQUESTS'] = False
+
+    def test_generic_exception_delegate_transaction_management(self):
+        """
+        Transaction is eventually managed by outer-most transaction atomic
+        block. DRF do not try to interfere here.
+
+        We let django deal with the transaction when it will catch the Exception.
+        """
+        request = factory.post('/')
+        with self.assertNumQueries(3):
+            # 1 - begin savepoint
+            # 2 - insert
+            # 3 - release savepoint
+            with transaction.atomic():
+                self.assertRaises(Exception, self.view, request)
+                self.assertFalse(transaction.get_rollback())
+        assert BasicModel.objects.count() == 1
+
+
+@skipUnless(connection.features.uses_savepoints,
+            "'atomic' requires transactions and savepoints.")
+class DBTransactionAPIExceptionTests(TestCase):
+    def setUp(self):
+        self.view = APIExceptionView.as_view()
+        connections.databases['default']['ATOMIC_REQUESTS'] = True
+
+    def tearDown(self):
+        connections.databases['default']['ATOMIC_REQUESTS'] = False
+
+    def test_api_exception_rollback_transaction(self):
+        """
+        Transaction is rollbacked by our transaction atomic block.
+        """
+        request = factory.post('/')
+        num_queries = (4 if getattr(connection.features,
+                                    'can_release_savepoints', False) else 3)
+        with self.assertNumQueries(num_queries):
+            # 1 - begin savepoint
+            # 2 - insert
+            # 3 - rollback savepoint
+            # 4 - release savepoint (django>=1.8 only)
+            with transaction.atomic():
+                response = self.view(request)
+                self.assertTrue(transaction.get_rollback())
+        self.assertEqual(response.status_code,
+                         status.HTTP_500_INTERNAL_SERVER_ERROR)
+        assert BasicModel.objects.count() == 0