Repository: akvo/akvo-rsr

Issue:
Reordering of Results/Indicators raises a bunch of errors
The code for reordering results/indicators assumes that the order attribute of siblings is set to have values `range(0, n)`. But, probably due to some concurrency issues this isn't always the case, and has caused a bunch of errors on the live server. 

http://sentry.support.akvo-ops.org/rsr/live/group/1092/
http://sentry.support.akvo-ops.org/rsr/live/group/1093/
http://sentry.support.akvo-ops.org/rsr/live/group/1094/
http://sentry.support.akvo-ops.org/rsr/live/group/1095/
http://sentry.support.akvo-ops.org/rsr/live/group/1096/


Discussion:

Resolving patch:
diff --git a/akvo/rest/views/project_editor.py b/akvo/rest/views/project_editor.py
--- a/akvo/rest/views/project_editor.py
+++ b/akvo/rest/views/project_editor.py
@@ -23,6 +23,7 @@
 from django.contrib.admin.models import LogEntry, CHANGE, ADDITION
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import MultipleObjectsReturned, ValidationError
+from django.db import transaction
 from django.db.models import (get_model, BooleanField, DateField, DecimalField, EmailField,
                               ForeignKey, ManyToManyField, NullBooleanField, PositiveIntegerField,
                               PositiveSmallIntegerField, URLField)
@@ -583,6 +584,7 @@ def project_editor(request, pk=None):
 
 @api_view(['POST'])
 @permission_classes((IsAuthenticated,))
+@transaction.atomic
 def project_editor_reorder_items(request, project_pk=None):
     """API call to reorder results or indicators"""
 
@@ -602,8 +604,8 @@ def project_editor_reorder_items(request, project_pk=None):
         errors += ['Invalid item type']
 
     if not errors:
-        # assign order if it doesn't already exist
-        if item_list and not item_list[0].order:
+        # assign order if it doesn't already exist or is messed up
+        if item_list and set(item_list.values_list('order', flat=True)) != set(range(item_list.count())):
             for i, item in enumerate(item_list):
                 item.order = i
                 item.save()


Test patch:
diff --git a/akvo/rsr/tests/rest/test_project_editor.py b/akvo/rsr/tests/rest/test_project_editor.py
new file mode 100644
--- /dev/null
+++ b/akvo/rsr/tests/rest/test_project_editor.py
@@ -0,0 +1,286 @@
+# -*- coding: utf-8 -*-
+
+"""
+Akvo RSR is covered by the GNU Affero General Public License.
+
+See more details in the license.txt file located at the root folder of the Akvo RSR module.
+For additional details on the GNU license please see < http://www.gnu.org/licenses/agpl.html >.
+"""
+
+from akvo.rsr.models import Employment, Indicator, Organisation, Partnership, Project, Result, User
+from akvo.utils import check_auth_groups
+
+from django.conf import settings
+from django.contrib.auth.models import Group
+from django.test import TestCase, Client
+
+
+class BaseReorderTestCase(object):
+
+    def setUp(self):
+        # Create necessary groups
+        check_auth_groups(settings.REQUIRED_AUTH_GROUPS)
+
+        # Create project
+        self.project = Project.objects.create(title="Test Project")
+
+        # Create reporting organisation
+        self.reporting_org = Organisation.objects.create(
+            name="REST reporting",
+            long_name="REST reporting org",
+            new_organisation_type=22
+        )
+
+        # Create partnership
+        Partnership.objects.create(
+            project=self.project,
+            organisation=self.reporting_org,
+            iati_organisation_role=Partnership.IATI_REPORTING_ORGANISATION,
+        )
+
+        # Create active user
+        self.username = 'username'
+        self.password = 'password'
+        self.user = User.objects.create_user(
+            username=self.username,
+            email="user.rest@test.akvo.org",
+            password=self.password,
+        )
+        self.user.is_active = True
+        self.user.save()
+
+        # Create employment
+        Employment.objects.create(
+            user=self.user,
+            organisation=self.reporting_org,
+            group=Group.objects.get(name='Admins'),
+            is_approved=True,
+        )
+
+        self.c = Client(HTTP_HOST=settings.RSR_DOMAIN)
+
+    def tearDown(self):
+        Project.objects.all().delete()
+        User.objects.all().delete()
+        Organisation.objects.all().delete()
+
+    def test_should_reorder_item_up(self):
+        """
+        Checks the regular REST project endpoint.
+        """
+
+        # Given
+        items = [self.create_item() for _ in range(5)]
+        item_id = items[1].id
+        url = '/rest/v1/project/{}/reorder_items/'.format(self.project.id)
+        data = {
+            'item_type': self.item_type,
+            'item_id': item_id,
+            'item_direction': 'up',
+            'format': 'json'
+        }
+        self.c.login(username=self.username, password=self.password)
+
+        # When
+        response = self.c.post(url, data=data, follow=True)
+
+        # Then
+        items = self.get_items()
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(items[0].order, 1)
+        self.assertEqual(items[1].order, 0)
+        self.assertEqual(items[2].order, 2)
+        self.assertEqual(items[3].order, 3)
+        self.assertEqual(items[4].order, 4)
+
+    def test_should_not_reorder_first_item_up(self):
+        # Given
+        items = [self.create_item() for _ in range(5)]
+        item_id = items[0].id
+        url = '/rest/v1/project/{}/reorder_items/'.format(self.project.id)
+        data = {
+            'item_type': self.item_type,
+            'item_id': item_id,
+            'item_direction': 'up',
+            'format': 'json'
+        }
+        self.c.login(username=self.username, password=self.password)
+
+        # When
+        response = self.c.post(url, data=data, follow=True)
+
+        # Then
+        items = self.get_items()
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(items[0].order, 0)
+        self.assertEqual(items[1].order, 1)
+        self.assertEqual(items[2].order, 2)
+        self.assertEqual(items[3].order, 3)
+        self.assertEqual(items[4].order, 4)
+
+    def test_should_reorder_item_down(self):
+        # Given
+        items = [self.create_item() for _ in range(5)]
+        item_id = items[0].id
+        url = '/rest/v1/project/{}/reorder_items/'.format(self.project.id)
+        data = {
+            'item_type': self.item_type,
+            'item_id': item_id,
+            'item_direction': 'down',
+            'format': 'json'
+        }
+        self.c.login(username=self.username, password=self.password)
+
+        # When
+        response = self.c.post(url, data=data, follow=True)
+
+        # Then
+        items = self.get_items()
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(items[0].order, 1)
+        self.assertEqual(items[1].order, 0)
+        self.assertEqual(items[2].order, 2)
+        self.assertEqual(items[3].order, 3)
+        self.assertEqual(items[4].order, 4)
+
+    def test_should_not_reorder_last_item_down(self):
+        # Given
+        items = [self.create_item() for _ in range(5)]
+        item_id = items[4].id
+        url = '/rest/v1/project/{}/reorder_items/'.format(self.project.id)
+        data = {
+            'item_type': self.item_type,
+            'item_id': item_id,
+            'item_direction': 'down',
+            'format': 'json'
+        }
+        self.c.login(username=self.username, password=self.password)
+
+        # When
+        response = self.c.post(url, data=data, follow=True)
+
+        # Then
+        items = self.get_items()
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(items[0].order, 0)
+        self.assertEqual(items[1].order, 1)
+        self.assertEqual(items[2].order, 2)
+        self.assertEqual(items[3].order, 3)
+        self.assertEqual(items[4].order, 4)
+
+    def test_should_handle_deleted_item(self):
+        # Given
+        self.c.login(username=self.username, password=self.password)
+        items = [self.create_item() for _ in range(5)]
+        item_id = items[2].id
+        url = '/rest/v1/project/{}/reorder_items/'.format(self.project.id)
+        data = {
+            'item_type': self.item_type,
+            'item_id': item_id,
+            'item_direction': 'down',
+            'format': 'json'
+        }
+        self.c.post(url, data=data, follow=True)
+
+        # When
+        item = self.ItemModel.objects.get(id=item_id)
+        assert item.order is not None
+        item.delete()
+
+        # Then
+        items = self.get_items()
+        self.assertEqual(len(items), 4)
+        self.assertEqual(items[0].order, 0)
+        self.assertEqual(items[1].order, 1)
+        self.assertEqual(items[2].order, 2)
+        self.assertEqual(items[3].order, 3)
+
+    def test_should_handle_creating_new_item(self):
+        # Given
+        # Try to move first item up, to set order column
+        self.c.login(username=self.username, password=self.password)
+        items = [self.create_item() for _ in range(4)]
+        item_id = items[0].id
+        url = '/rest/v1/project/{}/reorder_items/'.format(self.project.id)
+        data = {
+            'item_type': self.item_type,
+            'item_id': item_id,
+            'item_direction': 'up',
+            'format': 'json'
+        }
+        self.c.post(url, data=data, follow=True)
+
+        # When
+        item = self.create_item()
+
+        # Then
+        items = self.get_items()
+        self.assertEqual(len(items), 5)
+        self.assertEqual(items[0].order, 0)
+        self.assertEqual(items[1].order, 1)
+        self.assertEqual(items[2].order, 2)
+        self.assertEqual(items[3].order, 3)
+        self.assertEqual(items[4].id, item.id)
+        self.assertEqual(items[4].order, 4)
+
+    def test_should_handle_messed_up_order(self):
+        # Given
+        self.c.login(username=self.username, password=self.password)
+        order = [2, 3, 3, 6, 6]
+        items = [self.create_item(order=i) for i in order]
+        item_id = items[0].id
+        url = '/rest/v1/project/{}/reorder_items/'.format(self.project.id)
+        data = {
+            'item_type': self.item_type,
+            'item_id': item_id,
+            'item_direction': 'down',
+            'format': 'json'
+        }
+
+        # When
+        self.c.post(url, data=data, follow=True)
+
+        # Then
+        items = self.get_items()
+        self.assertEqual(len(items), 5)
+        self.assertEqual(items[0].order, 1)
+        self.assertEqual(items[1].order, 0)
+        self.assertEqual(items[2].order, 2)
+        self.assertEqual(items[3].order, 3)
+        self.assertEqual(items[4].order, 4)
+
+    def create_item(self, order=None):
+        raise NotImplementedError
+
+    def get_items(self):
+        raise NotImplementedError
+
+
+class ProjectEditorReorderResultsTestCase(BaseReorderTestCase, TestCase):
+    """Tests the reordering of results in the project editor."""
+
+    item_type = 'result'
+    ItemModel = Result
+
+    def create_item(self, order=None):
+        return Result.objects.create(project_id=self.project.id, order=order)
+
+    def get_items(self):
+        return Result.objects.filter(project__id=self.project.id).order_by('id')
+
+
+class ProjectEditorReorderIndicatorsTestCase(BaseReorderTestCase, TestCase):
+    """Tests the reordering of indicators in the project editor."""
+
+    item_type = 'indicator'
+    ItemModel = Indicator
+
+    def setUp(self):
+        super(ProjectEditorReorderIndicatorsTestCase, self).setUp()
+        self.result = Result.objects.create(project_id=self.project.id)
+
+    def create_item(self, order=None):
+        return Indicator.objects.create(result_id=self.result.id, order=order)
+
+    def get_items(self):
+        return Indicator.objects.filter(result_id=self.result.id).order_by('id')