如何使用 Django Rest Framework 交换嵌套数据列表中的两个项目

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

我正在尝试交换列表中的两个项目,并且想知道最好的方法。我能找到的唯一解决方案不保留项目的 pk,这是一个问题,因为其他对象依赖于它。

我正在使用 Django 2.0.10 和 Django Rest Framework。

我有嵌套数据,其中列表包含的项目数量有限。

每个项目都有一个顺序,它是一个整数并且在该列表中必须是唯一的,并且每个列表只能有固定数量的值。

假定所有列表始终具有最大项目数。

我想允许用户在列表中上下移动项目,这意味着交换两个项目。执行此操作的最简单方法是修改每个项目的 'order' 属性,但鉴于所有有效的订单值都已在使用中,我看不到如何执行此操作。我不能给项目 1 订单 2 并保存它,因为已经有一个项目 2。并且在交换操作期间没有我可以分配的临时值。

所以,我正在做的是:

  1. 创建每个项目的深层副本
  2. 将新订单分配给每个副本
  3. 删除原来的两个项目
  4. 设置每个副本的pk为None
  5. copy_1.save() 和 copy_2.save() 创建新项目

这可行,但当然每个新对象都有一个与原始对象不同的主键。我的项目有一个 slug,这意味着我仍然可以识别原始项目并 link 到它,但是项目的子对象现在已经失去了它们的引用。

这似乎是其他人过去做过的事情。

有没有办法在创建对象后更新 pk 而不允许其他操作编辑 pk,或者用新的订单值保存项目并避免冲突?

我想我可以在数据库中搜索引用已删除/替换项目的任何其他对象,但是当只有两个数字需要更改时,这是一个丑陋的解决方案!

非常感谢任何建议!

这是我的代码:

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)

摘自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)

最后我删除了 unique_together 约束,这似乎与更改项目顺序不兼容。很遗憾,因为乍一看约束似乎非常有用并且在 example in the docs 中,但我认为在实践中您需要重新排序项目的选项。

没有约束,您可以简单地更改每个项目的顺序并保存它,但是您需要手动确保每个项目在列表中具有唯一的顺序。

我添加了一个自定义更新方法,我认为它可以防止订单被任何普通操作编辑。我认为这是安全的,但我感觉不如使用数据库约束那么自信。

这是我的工作代码。

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