掌握Django(九)

本文设计和实现购物车API, 是对之前知识的巩固和练习.


设计API

对于购物车, 我们可能需设计以下API:

  • 创建购物车
  • 添加商品到购物车
  • 修改购物车里商品的数量
  • 从购物车删除商品
  • 获取购物车里所有的商品清单
  • 删除购物车.

我们拿出一张纸设计一下, 简单设计包括请求方法, 请求 url, 请求体及响应.

首先是购物车. 注意创建购物车无需提供用户或顾客ID, 所以POST请求体为空.

然后购物车清单条目:

所以 API端点就是 4个, 设计两个视图集来实现:

在开始编码实现API之前, 我们先修订下购物车数据模型.


修改购物车模型

Cart模型目前只有一个created_at字段, 我们知道, Django会自动生成一个主键ID, 但这个主键是数字型, 1,2,3,4…, 黑客很容易猜到这个ID, 最好将它改成GUID. GUID是32位的一串字符, 黑客是很难猜到的. 注意UUID加上primary_key=True, 表示这就是主键,这样Django就不会再生成主键, 另外default=uuid4 是引用, 不是函数调用, 如果写成 default = uuid4() , 就会成为硬编码导致默认的id都同一个值了.

from uuid import uuid4

class Cart(models.Model):

    id = models.UUIDField(primary_key=True, default=uuid4)

    created_at = models.DateTimeField(auto_now_add=True)

改成GUID后, 存储 Cart的主键的字节数由原先的数值8个字节 增加到 32个字节, CartItem表也同样增加, 但这点空间在现在不算什么事. 如果一定要节省空间, 可以在Cart表添加GUID字段, 保留原先的主键字段, 这样在CartItem表则用之前的数字主键, 但会降低性能, 因为相关查询等操作需要在数字主键与GUID中进行转换. 这里我们在CartItem的外键也使用GUID.

另外, 我们对CartItem也作一些小修改, 一是为方便理解设置 cart的related_name为items, 二是增加二个字段(cart和product)的联合唯一, 不能出现cart和product一样的多条记录.

class CartItem(models.Model):

    cart = models.ForeignKey(

        Cart, on_delete=models.CASCADE, related_name=’items’)

    product = models.ForeignKey(Product, on_delete=models.CASCADE)

    quantity = models.PositiveSmallIntegerField()   

    class Meta:

        unique_together = [[‘cart’, ‘product’]]

再进行迁移:

python manage.py makemigrations

python manage.py migrate


创建购物车

三个工作, 序列化类, 视图类以及 URL映射.

先看序列化类store– serializers.py, 我们只留id字段, 并设成只读:

class CartSerializer(serializers.ModelSerializer):

    class Meta:

        model = Cart

        fields = [‘id’]

    id = serializers.UUIDField(read_only=True)

再看视图类。store–views.py, 我们只使用添加操作, 就不用ModelViewSet,只继承CreateModelMinxin 和 通用视图集GenericViewSet.

class CartViewSet(CreateModelMixin, viewsets.GenericViewSet):

    serializer_class = CartSerializer

    queryset = Cart.objects.all()

再添加 url 映射:

router.register(“carts”, views.CartViewSet)

保测测试, POST一个空JSON对象, 会自动生成一个Cart对象.


获取一个购物车信息

获取购物车清单, 给出请求…/carts/<guid> ,返回如下格式的清单.

先修改序列类, 在CartSerializer中添加字段 items. 该字段是用了子序列类CartItemSerializer, 注意加上提供参数 many=True, 表示是一个多个对象的查询集

class CartItemSerializer(serializers.ModelSerializer):

    class Meta:

        model = CartItem

        fields = [‘id’, ‘product’, ‘quantity’]

class CartSerializer(serializers.ModelSerializer):

    id = serializers.UUIDField(read_only=True)

    items = CartItemSerializer(many=True)

    class Meta:

        model = Cart

        fields = [‘id’, ‘items’]

添加CartViewSet, 只使用获取单个对象的通用视图RetrieveModelMinxin.

class CartViewSet(RetrieveModelMixin, viewsets.GenericViewSet):

    serializer_class = CartSerializer

    queryset = Cart.objects.all()

再添加 url 映射:

    router.register(“carts”, views.CartViewSet)

对于购物车清单的Product, 显示是主键, 我们肯定希望显示名称和价格, 修改CartItemSerializer 的字段 product为 子序列类. 如果使用ProductSerializer类, 将会显示所有字段, 这里我们只需title 和 unit_price , 所以添加一个SimpleProductSerializer序列类:

class SimpleProductSerializer(serializers.ModelSerializer):

    class Meta:

    model = Product

    fields = [‘id’, ‘title’, ‘unit_price’]

class CartItemSerializer(serializers.ModelSerializer):

    product = SimpleProductSerializer()

    …

现在添加购物车每个商品的的总价, 也就是单价乘以数量, 添加字段 total_price.

class CartItemSerializer(serializers.ModelSerializer):

    …

    total_price = serializers.SerializerMethodField()

    def get_total_price(self, cartitem: CartItem):

        return cartitem.quantity * cartitem.product.unit_price

    class Meta:

        model = CartItem

        fields = […, ‘total_price’]

我们在购物车也添加总价 total_price:

class CartSerializer(serializers.ModelSerializer):

    …

    total_price = serializers.SerializerMethodField()

    def get_total_price(self, cart: Cart):

    return sum([item.product.unit_price*item.quantity for item in cart.items.all()])

    class Meta:

        model = Cart

        fields = […, ‘total_price’]

我们加总时用到了 列表推导, 如果不懂列表推导的话, 请查看《完全掌握Python》系列. 另外请注意 cart.items返回的是管理器, 需要加上all()

大部分完成了, 但我们查看Django网页调试工具, 发现多产生了很多SQL语句, 这是由于延迟查询引起, 我们修改 views 使之先期获取prefetch_related, 免得影响性能.

class CartViewSet(RetrieveModelMixin, viewsets.GenericViewSet):

    …

    queryset = Cart.objects.prefetch_related(‘items__product’)

这里prefetch_related参数的items__product 表示预加载 items以及每条item对应的的product.


删除购物车

非常简单,只需让CartViewSet继承加上 DestroyModelMixin 即可.

class CartViewSet(…,DestroyModelMixin, viewsets.GenericViewSet):

    …

顺便修复前面获取购物车清单的一个疏忽, 让 items 字段为 只读, 否则新建购物车会出错.

class CartSerializer(serializers.ModelSerializer):

    …

    items = CartItemSerializer(…, read_only=True)


获取购物车清单

我们希望的结果是:

 ../carts/(GUID)/items , 可以获得购物车的商品清单;

../carts/(GUID)/items/(id), 可以得到具体的商品及数量

实现也比较简单, 首先添加 CartItemViewSet类, 其中queryset需要筛选到特定的购物车主键 cart_pk

class CartItemViewSet(viewsets.ModelViewSet):

    serializer_class = CartItemSerializer   

    def get_queryset(self):

        return CartItem.objects.filter(cart=self.kwargs[‘cart_pk’])

然后添加url映射, 仿照之前的 ReviewViewSet, 在store–urls.py里添加:

carts_router = routers.NestedDefaultRouter(router, ‘carts’, lookup=’cart’)

carts_router.register(‘items’,views.CartItemViewSet,basename=’cart-items’)

urlpatterns = router.urls + products_router.urls + carts_router.urls

保存即可. 刷新网页, 功能是完成的. 但查看调试工具, 能看到每一个商品都要去调一次数据库的SQL执行, 我们预先加载优化下: 修改 CartItemViewSet类的get_queryset方法, 先加载product对象

def get_queryset(self):

    return CartItem.objects\

    .filter(cart=self.kwargs[‘cart_pk’])\

    .select_related(‘product’)


添加商品到购物车

根据我们之前的设计, 添加到购买车一般只需要POST类似这样的JSON就可以了

product_id: XXX, 

quantity: XXX

}

但目前CartItemSerializer 包括了Product关系的明细, 如果还是以这个为序列化类的话, 会很麻烦. 我们可以选择再创建一个序列化类,比如命名为 AddCartItemSerializer, 然后简化相关字段.

class AddCartItemSerializer(serializers.ModelSerializer):

    product_id = serializers.IntegerField()

    class Meta:

        model = CartItem

        fields = [‘id’, ‘product_id’, ‘quantity’]

然后在 视图中进行判断, 如果方法是POST的话 就用AddCartItemSerializer. 如下:

class CartItemViewSet(viewsets.ModelViewSet):

    def get_serializer_class(self):

        if self.request.method == ‘POST’:

            return AddCartItemSerializer

        return CartItemSerializer

        …

添加商品到购物车, 不能能简单用默认的序列类的实现. 因为如果有用户添加的商品在原购物车已有的话, 只需加上数量, 而不是添加一条记录. 我们重载save方法.

class AddCartItemSerializer(serializers.ModelSerializer):

    …

    def save(self, **kwargs):

        product_id = self.validated_data[‘product_id’]

        quantity = self.validated_data[‘quantity’]

        cart_id = self.context[‘cart_id’]

        try:

            cart_item = CartItem.objects.get(

                cart_id=cart_id, product_id=product_id)

            cart_item.quantity += quantity

            cart_item.save()

            self.instance = cart_item

        except CartItem.DoesNotExist:

            self.instance = CartItem.objects.create(

                cart_id=cart_id, **self.validated_data)

        return self.instance

我们判断商品是否已同一购物车里, 如果存在的话, 就增加数量. 如果不存在, 就抛出异常来执行添加记录. 返回 instance 是查看父类的save方法需要返回instance, 所以也作相应实现.

另外, cart_id 需要从上下文上获取, 我们需要修改 视图类, 获取序列的上下文.在store–views.py中, 添加 CartItemViewSet类的 get_serializer_context方法

class CartItemViewSet(viewsets.ModelViewSet):

    …

    def get_serializer_context(self):

        return {“cart_id”: self.kwargs[‘cart_pk’]}

快完成了, 目前我们没有验证 POST的数据的有效性, 比如有人传递给我们的json 体的 {product_id: 0 ,…} 的话, 将会出错, 因为我们找不到 product_id为 0 的商品, 我们来添加有效性检.

之前我们谈到可以对Serializer进行数据验证, 用 validate 进行整个对象的验证,  也可以单独对某个字段进行验证. 方法也很简单, 以“validate_ ”开头加字段名即可.

class AddCartItemSerializer(serializers.ModelSerializer):

    product_id = serializers.IntegerField()

    def validate_product_id(self, value):

        if not Product.objects.filter(pk=value).exists():

            raise serializers.ValidationError(

                ‘No product with the given ID was found.’)

            return value

我们再试下输入product_id为0时, 就会提示没找到 . 再来看下quantity, 如果输入 -1 的话, 也会提示, 因为CartItem的模型设了quantity类型PositiveSmallIntegerField 为非负数 , 我们可提升下,  使之大于0 .

class CartItem(models.Model):

    …

    quantity = models.PositiveSmallIntegerField(

        validators=[MinValueValidator(1)])

    …


更新购物车商品数量

更新我们也需要自定义一个序列类, 因为只需要quantity字段.

class UpdateCartItemSerializer(serializers.ModelSerializer):

    class Meta:

        model = CartItem

        fields = [‘quantity’]

然后, 在视图类进行判断HTTP方法, 选择不同的序列类.   

class CartItemViewSet(viewsets.ModelViewSet):

    http_method_names = [‘get’, ‘post’, ‘patch’, ‘delete’]

    def get_serializer_class(self):

        if self.request.method == ‘POST’:

            return AddCartItemSerializer

        elif self.request.method == ‘PATCH’:

            return UpdateCartItemSerializer

        return CartItemSerializer

如果是PATCH方法的话, 就使用UpdateCartItemSerializer . 

另外, 我们用不到 PUT 方法, 所以在 http_method_names 里不包括它.


删除购物车商品

删除购物车商品前面已完成的, 因为继承于ModelViewSet, 默认已有实现的.我们可以测试下, 确认功能正常.

这里需要注意的是CartItemView类的属性 http_method_names 列出的各方法需要用小写, 如果使用了大写则是无效的.

    http_method_names = [‘get’, ‘post’, ‘patch’, ‘delete’]


小结

本文设计和实现了购物车API, 是对之前知识的巩固和练习.

下一篇将转向新话题, 开始介绍Django的用户认证.

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注