Search code examples
pythondjangodjango-rest-frameworkswap

How to swap two items in a list in nested data using Django Rest Framework


I'm trying to swap two items in a list, and would like to know the best way to do it. The only solution I can find doesn't preserve the pk of the items, which is a problem because other objects depend on it.

I'm using Django 2.0.10 with Django Rest Framework.

I have nested data where Lists contain a limited number of Items.

Each item has an order, which is an integer and must be unique within that list, and each list can only have a fixed number of values.

It is assumed that all lists always have their maxiumum number of items.

I want to allow the user to move items up and down in the list, which means swapping two items. The simplest way to do this would be to modify the 'order' attribute of each item, but I can't see how to do this given that all valid order values are already in use. I can't give item 1 the order 2 and save it, because there is already an item 2. And there is no temporary value I can assign during the swap operation.

So, what I'm doing instead is this:

  1. create a deep copy of each item
  2. assign the new order to each copy
  3. delete the two original items
  4. set the pk of each copy to None
  5. copy_1.save() and copy_2.save() to create new items

This works, but of course each new object has a different primary key from the original. My items have a slug which means that I can still identify the original item and link to it, but child objects of the Item have now lost their reference.

This seems like something other people will have done in the past.

Is there a way to either update the pk after the objects have been created without allowing pk to be editable by other operations, or to save the items with their new order values and avoid a conflict?

I guess I could hunt through the database for any other objects which reference the items which have been deleted / replaced, but it's an ugly solution when it's just two numbers that need to be changed!

Many thanks for any advice!

Here's my code:

models.py

"""Models for lists, items
    """
import uuid

from django.db import models
from django.utils.http import int_to_base36
from django.core.validators import MaxValueValidator, MinValueValidator
from django.contrib.auth import get_user_model

ID_LENGTH = 12
USER = get_user_model()

def slug_gen():
    """Generates a probably unique string that can be used as a slug when routing

    Starts with a uuid, encodes it to base 36 and shortens it
    """

    #from base64 import b32encode
    #from hashlib import sha1
    #from random import random

    slug = int_to_base36(uuid.uuid4().int)[:ID_LENGTH]
    return slug

class List(models.Model):
    """Models for lists
    """
    slug = models.CharField(max_length=ID_LENGTH, default=slug_gen, editable=False)
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    created_by_id = models.ForeignKey(USER, on_delete=models.CASCADE, related_name='list_created_by_id')
    created_by_username = models.CharField(max_length=255) # this shold be OK given that the list will be deleted if the created_by_id user is deleted
    created_at = models.DateTimeField(auto_now_add=True)
    parent_item = models.ForeignKey('Item', on_delete=models.SET_NULL, null=True, related_name='lists')
    modified_by = models.ForeignKey(USER, on_delete=models.SET_NULL, null=True,
        related_name='list_modified_by')
    modified_at = models.DateTimeField(auto_now_add=True)
    name = models.CharField(max_length=255)
    description = models.CharField(max_length=5000, blank=True, default='')
    is_public = models.BooleanField(default=False)

    def __str__(self):
        return self.name


class Item(models.Model):
    """Models for list items
    """
    slug = models.CharField(max_length=ID_LENGTH, default=slug_gen, editable=False)
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    modified_at = models.DateTimeField(auto_now_add=True)
    name = models.CharField(max_length=255, blank=True, default='')
    description = models.CharField(max_length=5000, blank=True, default='')
    list = models.ForeignKey(List, on_delete=models.CASCADE, related_name='items')
    order = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(10)])

    class Meta:
        unique_together = ('list', 'order')
        ordering = ['order']

    def __unicode__(self):
        return '%d: %s' % (self.order, self.name)

extract from api.py:

@detail_route(methods=['post'])
    def moveup(self, request, pk=None):

        if self.request.user.is_authenticated:
            # find the item to move up
            item = Item.objects.get(pk=pk)

            item_order = item.order
            parent_list = item.list

            if item.order == 1:
                return Response({'message': 'Item is already at top of list'}, status=status.HTTP_403_FORBIDDEN)

            item_copy = copy.deepcopy(item)

            # find the item above with which to swap the first item
            item_above = Item.objects.get(list=parent_list, order=item_order-1)
            item_above_copy = copy.deepcopy(item_above)

            # swap the order on the item copies
            item_copy.order = item_order-1
            item_above_copy.order = item_order

            # set pk to None so save() will create new objects
            item_copy.pk = None
            item_above_copy.pk = None

            # delete the original items to free up the order values for the new items
            item.delete()
            item_above.delete()

            # 
            item_copy.save()
            item_above_copy.save()

            return Response({'message': 'Item moved up'}, status=status.HTTP_200_OK)

        return Response(status=status.HTTP_401_UNAUTHORIZED)

Solution

  • In the end I removed the unique_together constraint, which seems to be incompatible with changing order of items. It's a shame because the constraint at first sight seems really useful and is in the example in the docs, but I think in practice you need the option of reordering items.

    Without the constraint you can simply change the order of each item and save it, but then you need to manually ensure that each item has a unique order within the list.

    I've added a custom update method which I think prevents the order from being edited by any ordinary operation. I think this is safe but I feel less confident than if I could have used a database constraint.

    Here's my working code.

    serializers.py

    class ItemSerializer(serializers.ModelSerializer):
        """
        An item must belong to a list
        """
        class Meta:
            model = Item
            fields = ('id', 'name', 'description', 'list_id', 'modified_at', 'order', 'slug')
            # note 'list_id' is the field that can be returned, even though 'list' is the actual foreign key in the model
    

    models.py

    """Models for lists, items
        """
    import uuid
    
    from django.db import models
    from django.utils.http import int_to_base36
    from django.core.validators import MaxValueValidator, MinValueValidator
    from django.contrib.auth import get_user_model
    
    ID_LENGTH = 12
    USER = get_user_model()
    
    def slug_gen():
        """Generates a probably unique string that can be used as a slug when routing
    
        Starts with a uuid, encodes it to base 36 and shortens it
        """
    
        #from base64 import b32encode
        #from hashlib import sha1
        #from random import random
    
        slug = int_to_base36(uuid.uuid4().int)[:ID_LENGTH]
        return slug
    
    class List(models.Model):
        """Models for lists
        """
        slug = models.CharField(max_length=ID_LENGTH, default=slug_gen, editable=False)
        id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
        created_by_id = models.ForeignKey(USER, on_delete=models.CASCADE, related_name='list_created_by_id')
        created_by_username = models.CharField(max_length=255) # this shold be OK given that the list will be deleted if the created_by_id user is deleted
        created_at = models.DateTimeField(auto_now_add=True)
        parent_item = models.ForeignKey('Item', on_delete=models.SET_NULL, null=True, related_name='lists')
        modified_by = models.ForeignKey(USER, on_delete=models.SET_NULL, null=True,
            related_name='list_modified_by')
        modified_at = models.DateTimeField(auto_now_add=True)
        name = models.CharField(max_length=255)
        description = models.CharField(max_length=5000, blank=True, default='')
        is_public = models.BooleanField(default=False)
    
        def __str__(self):
            return self.name
    
    
    class Item(models.Model):
        """Models for list items
        """
        slug = models.CharField(max_length=ID_LENGTH, default=slug_gen, editable=False)
        id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
        modified_at = models.DateTimeField(auto_now_add=True)
        name = models.CharField(max_length=255, blank=True, default='')
        description = models.CharField(max_length=5000, blank=True, default='')
        list = models.ForeignKey(List, on_delete=models.CASCADE, related_name='items')
        order = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(10)])
    
        class Meta:
            # unique_together = ('list', 'order') # prevents items from being swapped because deferred is not available in mysql
            ordering = ['order']
    
        def __unicode__(self):
            return '%d: %s' % (self.order, self.name)
    

    api.py

    from rest_framework import viewsets, permissions
    from rest_framework.decorators import detail_route
    from rest_framework import status
    from rest_framework.response import Response
    from rest_framework.exceptions import APIException
    
    from .models import List, Item
    from .serializers import ListSerializer, ItemSerializer
    from django.db.models import Q
    
    class ItemViewSet(viewsets.ModelViewSet):
        permission_classes = [IsOwnerOrReadOnly, HasVerifiedEmail]
        model = Item
        serializer_class = ItemSerializer
    
        def get_queryset(self):
            # can view items belonging to public lists and lists the user created
            if self.request.user.is_authenticated:
                return Item.objects.filter(
                    Q(list__created_by_id=self.request.user) | 
                    Q(list__is_public=True)
                )
    
            return Item.objects.filter(list__is_public=True)
    
        @detail_route(methods=['patch'])
        def moveup(self, request, pk=None):
    
            if self.request.user.is_authenticated:
                # find the item to move up
                item = Item.objects.get(pk=pk)       
                item_order = item.order
                parent_list = item.list_id # note 'list_id' not 'list'
    
                if item.order == 1:
                    return Response({'message': 'Item is already at top of list'}, status=status.HTTP_403_FORBIDDEN)
    
                # change the item order up one
                item.order = item.order - 1
    
                # find the existing item above
                item_above = Item.objects.get(list=parent_list, order=item_order-1)
                # and change its order down one
                item_above.order = item_order
    
                item.save()
                item_above.save()
    
                # return the new items so the UI can update
                items = [item, item_above]
    
                serializer = ItemSerializer(items, many=True)
    
                return Response(serializer.data, status=status.HTTP_200_OK)
    
            return Response(status=status.HTTP_401_UNAUTHORIZED)
    
        def perform_update(self, serializer):
            # do not allow order to be changed
            if serializer.validated_data.get('order', None) is not None:
                raise APIException("Item order may not be changed. Use moveup instead.")
    
            serializer.save()