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:
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)
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()