第七章 创建电商网站

在上一章里,创建了用户关注系统和行为流应用,还学习了使用Django的信号功能与使用Redis数据库存储图片浏览次数和排名。这一章将学习如何创建一个基础的电商网站。本章将学习创建商品品类目录,通过session实现购物车功能。还将学习创建自定义上下文管理器和使用Celery执行异步任务。

本章的要点有:

1创建电商网站项目

我们要创建一个电商网站项目。用户能够浏览商品品类目录,然后将具体商品加入购物车,最后还可以通过购物车生成订单。本章电商网站的如下功能:

打开系统命令行窗口,为新项目配置一个新的虚拟环境并激活:

mkdir env
virtualenv env/myshop
source env/myshop/bin/activate

然后在虚拟环境中安装Django:

pip install Django==2.0.5

新创建一个项目叫做myshop,之后创建新应用叫shop

django-admin startproject myshop
cd myshop/
django-admin startapp shop

编辑settings.py文件,激活shop应用:

INSTALLED_APPS = [
    # ...
    'shop.apps.ShopConfig',
]

现在应用已经激活,下一步是设计数据模型。

1.1创建商品品类模型

我们的商品品类模型包含一系列商品大类,每个商品大类中包含一系列商品。每一个商品都有一个名称,可选的描述,可选的图片,价格和是否可用属性。编辑shop应用的models.py文件:

from django.db import models


class Category(models.Model):
    name = models.CharField(max_length=200, db_index=True)
    slug = models.SlugField(max_length=200, db_index=True, unique=True)

    class Meta:
        ordering = ('name',)
        verbose_name = 'category'
        verbose_name_plural = 'categories'

    def __str__(self):
        return self.name


class Product(models.Model):
    category = models.ForeignKey(Category, related_name='category', on_delete=models.CASCADE)
    name = models.CharField(max_length=200, db_index=True)
    slug = models.SlugField(max_length=200, db_index=True)
    image = models.ImageField(upload_to='products/%Y/%m/%d', blank=True)
    description = models.TextField(blank=True)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    available = models.BooleanField(default=True)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ('name',)
        index_together = (('id', 'slug'),)

    def __str__(self):
        return self.name

这是我们的CategoryProduct模型。Category包含name字段和设置为不可重复的slug字段(unique同时也意味着创建索引)。Product模型的字段如下:

这里需要特别说明的是price字段,使用DecimalField,而不是FloatField,以避免小数尾差。

凡是涉及到金额相关的数值,使用DecimalField字段。FloatField的后台使用Python的float类型,而DecimalField字段后台使用Python的Decimal类,可以避免出现浮点数的尾差。

Product模型的Meta类中,使用index_together设置idslug字段建立联合索引,这样在同时使用两个字段的索引时会提高效率。

由于使用了ImageField,还需要安装Pillow库:

pip install Pillow==5.1.0

之后执行数据迁移程序,创建数据表。

1.2将模型注册到管理后台

将我们的模型都添加到管理后台中,编辑shop应用的admin.py文件:

from django.contrib import admin
from .models import Category, Product

@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
    list_display = ['name', 'slug']
    prepopulated_fields = {'slug': ('name',)}

@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
    list_display = ['name', 'slug', 'price', 'available', 'created', 'updated']
    list_filter = ['available', 'created', 'updated']
    list_editable = ['price', 'available']
    prepopulated_fields = {'slug': ('name',)}

我们使用了prepopulated_fields用于让slug字段通过name字段自动生成,在之前的项目中可以看到这么做很简便。在ProductAdmin中使用list_editable设置了可以编辑的字段,这样可以一次性编辑多行而不用点开每一个对象。注意所有在list_editable中的字段必须出现在list_display中。

之后创建超级用户。打开http://127.0.0.1:8000/admin/shop/product/add/,使用管理后台添加一个新的商品品类和该品类中的一些商品,页面如下:

译者注:这里图片上有一个stock字段,这是上一版的程序使用的字段。在本书内程序已经修改,但图片依然使用了上一版的图片。本项目中后续并没有使用stock字段。

1.3创建商品品类视图

为了展示商品,我们创建一个视图,用于列出所有商品,或者根据品类显示某一品类商品,编辑shop应用的views.py文件:

from django.shortcuts import render, get_object_or_404
from .models import Category, Product

def product_list(request, category_slug=None):
    category = None
    categories = Category.objects.all()
    products = Product.objects.filter(available=True)
    if category_slug:
        category = get_object_or_404(categories, slug=category_slug)
        products = products.filter(category=category)
    return render(request, 'shop/product/list.html',
                  {'category': category, 'categories': categories, 'products': products})

这个视图逻辑较简单,使用了available=True筛选所有可用的商品。设置了一个可选的category_slug参数用于选出特定的品类。

还需要一个展示单个商品详情的视图,继续编辑views.py文件:

def product_detail(request, id, slug):
    product = get_object_or_404(Product, id=id, slug=slug, available=True)
    return render(request, 'shop/product/detail.html', {'product': product})

product_detail视图需要idslug两个参数来获取商品对象。只通过ID可以获得商品对象,因为ID是唯一的,这里增加了slug字段是为了对搜索引擎优化。

在创建了上述视图之后,需要为其配置URL,在shop应用内创建urls.py文件并添加如下内容:

from django.urls import path
from . import views

app_name = 'shop'

urlpatterns = [
    path('', views.product_list, name='product_list'),
    path('<slug:category_slug>/', views.product_list, name='product_list_by_category'),
    path('<int:id>/<slug:slug>/', views.product_detail, name='product_detail'),
]

我们为product_list视图定义了两个不同的URL,一个名称是product_list,不带任何参数,表示展示全部品类的全部商品;一个名称是product_list_by_category,带参数,用于显示指定品类的商品。还为product_detail视图配置了传入idslug参数的URL。

这里要解释的就是product_list视图带一个默认值参数,所以默认路径进来后就是展示全部品类的页面。加上了具体某个品类,就展示那个品类的商品。详情页的URL使用id和slug来进行参数传递。

还需要编写项目的一级路由,编辑myshop项目的根urls.py文件:

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('shop.urls', namespace='shop')),
]

我们为shop应用配置了名为shop的二级路由。

由于URL中有参数,就需要配置URL反向解析,编辑shop应用的models.py文件,导入reverse()函数,然后为CategoryProduct模型编写get_absolute_url()方法:

from django.urls import reverse

class Category(models.Model):
    # ......
    def get_absolute_url(self):
        return reverse('shop:product_list_by_category',args=[self.slug])

class Product(models.Model):
    # ......
    def get_absolute_url(self):
        return reverse('shop:product_detail',args=[self.id,self.slug])

这样就为模型的对象配置好了用于反向解析URL的方法,我们已经知道,get_absolute_url()是很好的获取具体对象规范化URL的方法。

1.4创建商品品类模板

现在需要创建模板,在shop应用下建立如下目录和文件结构:

templates/
    shop/
    base.html
    product/
        list.html
        detail.html

像以前的项目一样,base.html是母版,让其他的模板继承母版。编辑base.html

{% load static %}
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>{% block title %}My shop{% endblock %}</title>
    <link href="{% static "css/base.css" %}" rel="stylesheet">
</head>
<body>
    <div id="header">
        <a href="/" class="logo">My shop</a>
    </div>
    <div id="subheader">
        <div class="cart">Your cart is empty.</div>
    </div>
    <div id="content">
        {% block content %}
        {% endblock %}
    </div>
</body>
</html>

这是这个项目的母版。其中使用的CSS文件可以从随书源代码中复制到shop应用的static/目录下。

然后编辑shop/product/list.html

{% extends "shop/base.html" %}
{% load static %}
{% block title %}
    {% if category %}{{ category.name }}{% else %}Products{% endif %}
{% endblock %}
{% block content %}
    <div id="sidebar">
        <h3>Categories</h3>
        <ul>
            <li {% if not category %}class="selected"{% endif %}>
                <a href="{% url "shop:product_list" %}">All</a>
            </li>
            {% for c in categories %}
                <li {% if category.slug == c.slug %}class="selected"
                    {% endif %}>
                    <a href="{{ c.get_absolute_url }}">{{ c.name }}</a>
                </li>
            {% endfor %}
        </ul>
    </div>
    <div id="main" class="product-list">
        <h1>{% if category %}{{ category.name }}{% else %}Products
        {% endif %}</h1>
        {% for product in products %}
            <div class="item">
                <a href="{{ product.get_absolute_url }}">
                    <img src="
                            {% if product.image %}{{ product.image.url }}{% else %}{% static "img/no_image.png" %}{% endif %}">
                </a>
                <a href="{{ product.get_absolute_url }}">{{ product.name }}</a>
                <br>
                ${{ product.price }}
            </div>
        {% endfor %}
    </div>
{% endblock %}

这是展示商品列表的模板,继承了base.html,使用categories变量在侧边栏显示品类的列表,在页面主体部分通过products变量展示商品清单。展示所有商品和具体某一类商品都采用这个模板。如果Product对象的image字段为空,我们显示一张默认的图片,可以在随书源码中找到img/no_image.png,将其拷贝到对应的目录。

由于使用了Imagefield,还需要对媒体文件进行一些设置,编辑settings.py文件加入下列内容:

MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media/')

MEDIA_URL是保存用户上传的媒体文件的目录,MEDIA_ROOT是存放媒体文件的目录,通过BASE_DIR变量动态建立该目录。

为了让Django提供静态文件服务,还必须修改shop应用的urls.py文件:

from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
    # ...
]
if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

注意仅在开发阶段才能如此设置。在生产环境中不能使用Django提供静态文件。使用管理后台增加一些商品,然后打开http://127.0.0.1:8000/,可以看到如下页面:

如果没有给商品上传图片,则会显示no_image.png,如下图:

然后编写商品详情页shop/product/detail.html

{% extends "shop/base.html" %}
{% load static %}
{% block title %}
    {{ product.name }}
{% endblock %}
{% block content %}
    <div class="product-detail">
        <img src="{% if product.image %}{{ product.image.url }}{% else %}
        {% static "img/no_image.png" %}{% endif %}">
        <h1>{{ product.name }}</h1>
        <h2><a href="{{ product.category.get_absolute_url }}">{{ product.category }}</a></h2>
        <p class="price">${{ product.price }}</p>
        {{ product.description|linebreaks }}
    </div>
{% endblock %}

在模板中调用get_absolute_url()方法用于展示对应类的商品,打开http://127.0.0.1:8000/,然后点击任意一个商品,详情页如下:

现在已经将商品品类和展示功能创建完毕。

2创建购物车功能

在建立商品品类之后,下一步是创建一个购物车,让用户可以将指定的商品及数量加入购物车,而且在浏览整个网站并且下订单之前,购物车都会维持其中的信息。为此,我们需要将购物车数据存储在当前用户的session中。

由于session通用翻译成会话,而在本章中很多时候session指的是Django的session模块或者session对象,所以不再进行翻译。

我们将使用Django的session框架来存储购物车数据。直到用户生成订单,商品信息都存储在购session中,为此我们还需要为购物车和其中的商品创建一个模型。

2.1使用Django的session模块

Django 提供了一个session模块,用于进行匿名或登录用户会话,可以为每个用户保存独立的数据。session数据存储在服务端,通过在cookie中包含session ID就可以获取到session,除非将session存储在cookie中。session中间件管理具体的cookie信息,默认的session引擎将session保存在数据库内,也可以切换不同的session引擎。

要使用session,需要在settings.py文件的MIDDLEWARE设置中启用'django.contrib.sessions.middleware.SessionMiddleware',这个管理session中间件在使用startproject命令创建项目时默认已经被启用。

这个中间件在request对象中设置了session属性用于访问session数据,类似于一个字典一样,可以存储任何可以被序列化为JSON的Python数据类型。可以像这样存入数据:

request.session['foo'] = 'bar'

获取键对应的值:

request.session.get('foo')

删除一个键值对:

del request.session['foo']

可以将request.session当成字典来操作。

当用户登录到一个网站的时候,服务器会创建一个新的用于登录用户的session信息替代原来的匿名用户session信息,这意味着原session信息会丢失。如果想保存原session信息,需要在登录的时候将原session信息存为一个新的session数据。

2.2session设置

Django中可以配置session模块的一些参数,其中最重要的是SESSION_ENGINE设置,即设置session数据具体存储在何处。默认情况下,Django通过django.contrib.session应用的Session模型,将session数据保存在数据库中的django_session数据表中。

Django提供了如下几种存储session数据的方法:

为了提高性能,使用基于缓存的session是好的选择。Django直接支持基于Memcached的缓存和如Redis的第三方缓存后端。

还有其他一系列的session设置,以下是一些主要的设置:

可以在https://docs.djangoproject.com/en/2.0/ref/settings/#sessions查看所有的session设置和默认值。

2.3session过期

特别需要提的是SESSION_EXPIRE_AT_BROWSER_CLOSE设置。该设置默认为False,此时session有效时间采用SESSION_COOKIE_AGE中的设置。

如果将SESSION_EXPIRE_AT_BROWSER_CLOSE设置为True,则session在浏览器关闭后就失效,SESSION_COOKIE_AGE设置不起作用。

还可以使用request.session.set_expiry()方法设置过期时间。

2.4在session中存储购物车数据

我们需要创建一个简单的数据结构,可以被JSON序列化,用于存放购物车数据。购物车中必须包含如下内容:

由于商品的价格会变化,我们在将商品加入购物车的同时存储当时商品的价格,如果商品价格之后再变动,也不进行处理。

现在需要实现创建购物车和为session添加购物车的功能,购物车按照如下方式工作:

  1. 当需要创建一个购物车的时候,先检查session中是否存在自定义的购物车键,如果存在说明当前用户已经使用了购物车,如果不存在,就新建一个购物车键。
  2. 对于接下来的HTTP请求,都要重复第一步,并且从购物车中保存的商品ID到数据库中取得对应的Product对象数据。

编辑settings.py里新增一行:

CART_SESSION_ID = 'cart'

这就是我们的购物车键名称,由于session对于每个用户都通过中间件管理,所以可以在所有用户的session里都使用统一的这个名称。

然后新建一个应用来管理购物车,启动系统命令行并创建新应用cart

python manage.py startapp cart

然后在settings.py中激活该应用:

INSTALLED_APPS = [
    # ...
    'shop.apps.ShopConfig',
    'cart.apps.CartConfig',
]

cart应用中创建cart.py,添加如下代码:

from decimal import Decimal
from django.conf import settings
from shop.models import Product

class Cart:

    def __init__(self, request):
        """
        初始化购物车对象
        """
        self.session = request.session
        cart = self.session.get(settings.CART_SESSION_ID)
        if not cart:
            # 向session中存入空白购物车数据
            cart = self.session[settings.CART_SESSION_ID] = {}
        self.cart =cart

这是我们用于管理购物车的Cart类,使用request对象进行初始化,使用self.session = request.session让类中的其他方法可以访问session数据。首先,使用self.session.get(settings.CART_SESSION_ID)尝试获取购物车对象。如果不存在购物车对象,通过为购物车键设置一个空白字段对象从而新建一个购物车对象。我们将使用商品ID作为字典中的键,其值又是一个由数量和价格构成的字典,这样可以保证不会重复生成同一个商品的购物车数据,也简化了取出购物车数据的方式。

创建将商品添加到购物车和更新数量的方法,为Cart类添加add()save()方法:

class Cart:
    # ......
    def add(self, product, quantity=1, update_quantity=False):
        """
        向购物车中增加商品或者更新购物车中的数量
        """

        product_id = str(product.id)
        if product_id not in self.cart:
            self.cart[product_id] = {'quantity': 0, 'price': str(product.price)}
        if update_quantity:
            self.cart[product_id]['quantity'] = quantity
        else:
            self.cart[product_id]['quantity'] += quantity
        self.save()

    def save(self):
    # 设置session.modified的值为True,中间件在看到这个属性的时候,就会保存session
        self.session.modified = True

add()方法接受以下参数:

我们把商品的ID转换成字符串形式然后作为购物车中商品键名,这是因为Django使用JSON序列化session数据,而JSON只允许字符串作为键名。商品价格也被从decimal类型转换为字符串,同样是为了序列化。最后,使用save()方法把购物车数据保存进session。

save()方法中修改了session.modified = True,中间件通过这个判断session已经改变然后存储session数据。

我们还需要从购物车中删除商品的方法,为Cart类添加以下方法:

class Cart:
    # ......
    def remove(self, product):
        """
        从购物车中删除商品
        """
        product_id = str(product.id)
        if product_id in self.cart:
            del self.cart[product_id]
            self.save()

remove()根据id从购物车中移除对应的商品,然后调用save()方法保存session数据。

为了使用方便,我们会需要遍历购物车内的所有商品,用于展示等操作。为此需要在Cart类内定义__iter()__方法,生成迭代器,供将for循环使用。

class Cart:
    # ......
    def __iter__(self):
        """
        遍历所有购物车中的商品并从数据库中取得商品对象
        """
        product_ids = self.cart.keys()
        # 获取购物车内的所有商品对象
        products = Product.objects.filter(id__in=product_ids)

        cart = self.cart.copy()
        for product in products:
            cart[str(product.id)]['product'] = product

        for item in cart.values():
            item['price'] = Decimal(item['price'])
            item['total_price'] = item['price'] * item['quantity']
            yield item

__iter()__方法中,获取了当前购物车中所有商品的Product对象。然后浅拷贝了一份cart购物车数据,并为其中的每个商品添加了键为product,值为商品对象的键值对。最后迭代所有的值,为把其中的价格转换为decimal类,增加一个total_price键来保存总价。这样我们就可以迭代购物车对象了。

还需要显示购物车中有几件商品。当执行len()方法的时候,Python会调用对象的__len__()方法,为Cart类添加如下的__len__()方法:

class Cart:
    # ......
    def __len__(self):
        """
        购物车内一共有几种商品
        """
        return sum(item['quantity'] for item in self.cart.values())

这个方法返回所有商品的数量的合计。

再编写一个计算购物车商品总价的方法:

class Cart:
    # ......
    def get_total_price(self):
        return sum(Decimal(item['price']) * item['quantity'] for item in self.cart.values())

最后,再编写一个清空购物车的方法:

class Cart:
    # ......
    def clear(self):
        del self.session[settings.CART_SESSION_ID]
        self.save()

现在就编写完了用于管理购物车的Cart类。

译者注,原书的代码采用class Cart(object)的写法,译者将其修改为Python 3的新式类编写方法。

2.5创建购物车视图

现在我们拥有了管理购物车的Cart类,需要创建如下的视图来添加、更新和删除购物车中的商品

2.5.1购物车相关视图

为了向购物车内增加商品,显然需要一个表单让用户选择数量并按下添加到购物车的按钮。在cart应用中创建forms.py文件并添加如下内容:

from django import forms

PRODUCT_QUANTITY_CHOICES = [(i, str(i)) for i in range(1, 21)]


class CartAddProductForm(forms.Form):
    quantity = forms.TypedChoiceField(choices=PRODUCT_QUANTITY_CHOICES, coerce=int)
    update = forms.BooleanField(required=False, initial=False, widget=forms.HiddenInput)

使用该表单添加商品到购物车,这个CartAddProductForm表单包含如下两个字段:

创建向购物车中添加商品的视图,编写cart应用中的views.py文件,添加如下代码:

from django.shortcuts import render, redirect, get_object_or_404
from django.views.decorators.http import require_POST
from shop.models import Product
from .cart import Cart
from .form import CartAddProductForm

@require_POST
def cart_add(request, product_id):
    cart = Cart(request)
    product = get_object_or_404(Product, id=product_id)
    form = CartAddProductForm(request.POST)
    if form.is_valid():
        cd = form.cleaned_data
        cart.add(product=product, quantity=cd['quantity'], update_quantity=cd['update'])
    return redirect('cart:cart_detail')

这是添加商品的视图,使用@require_POST使该视图仅接受POST请求。这个视图接受商品ID作为参数,ID取得商品对象之后验证表单。表单验证通过后,将商品添加到购物车,然后跳转到购物车详情页面对应的cart_detail URL,稍后我们会来编写cart_detail URL。

再来编写删除商品的视图,在cart应用的views.py中添加如下代码:

def cart_remove(request, product_id):
    cart = Cart(request)
    product = get_object_or_404(Product, id=product_id)
    cart.remove(product)
    return redirect('cart:cart_detail')

删除商品视图同样接受商品ID作为参数,通过ID获取Product对象,删除成功之后跳转到cart_detail URL。

还需要一个展示购物车详情的视图,继续在cart应用的views.py文件中添加下列代码:

def cart_detail(request):
    cart = Cart(request)
    return render(request, 'cart/detail.html', {'cart': cart})

cart_detail视图用来展示当前购物车中的详情。现在已经创建了添加、更新、删除及展示的视图,需要配置URL,在cart应用里新建urls.py

from django.urls import path
from . import views

app_name = 'cart'
urlpatterns = [
    path('', views.cart_detail, name='cart_detail'),
    path('add/<int:product_id>/', views.cart_add, name='cart_add'),
    path('remove/<int:product_id>/', views.cart_remove, name='cart_remove'),
]

然后编辑项目的根urls.py,配置URL:

urlpatterns = [
    path('admin/', admin.site.urls),
    path('cart/', include('cart.urls', namespace='cart')),
    path('', include('shop.urls', namespace='shop')),
]

注意这一条路由需要增加在shop.urls路径之前,因为这一条比下一条的匹配路径更加严格。

2.5.2创建展示购物车的模板

cart_addcart_remove视图并未渲染模板,而是重定向到cart_detail视图,我们需要为编写展示购物车详情的模板。

cart应用内创建如下文件目录结构:

templates/
    cart/
        detail.html

编辑cart/detail.html,添加下列代码:

{% extends 'shop/base.html' %}

{% load static %}

{% block title %}
    Your shopping cart
{% endblock %}

{% block content %}
    <h1>Your shopping cart</h1>
    <table class="cart">
        <thead>
        <tr>
            <th>Image</th>
            <th>Product</th>
            <th>Quantity</th>
            <th>Remove</th>
            <th>Unit price</th>
            <th>Price</th>
        </tr>
        </thead>
        <tbody>
        {% for item in cart %}
            {% with product=item.product %}
                <tr>
                    <td>
                        <a href="{{ product.get_absolute_url }}">
                            <img src="
                                    {% if product.image %}{{ product.image.url }}{% else %}{% static 'img/no_image.png' %}{% endif %}"
                                 alt="">
                        </a>
                    </td>
                    <td>{{ product.name }}</td>
                    <td>{{ item.quantity }}</td>
                    <td>
                        <a href="{% url 'cart:cart_remove' product.id %}">Remove</a>
                    </td>
                    <td class="num">${{ item.price }}</td>
                    <td class="num">${{ item.total_price }}</td>
                </tr>
            {% endwith %}
        {% endfor %}

            <tr class="total">
                <td>total</td>
                <td colspan="4"></td>
                <td class="num">${{ cart.get_total_price }}</td>
            </tr>
        </tbody>
    </table>
    <p class="text-right">
        <a href="{% url 'shop:product_list' %}" class="button light">Continue shopping</a>
        <a href="#" class="button">Checkout</a>
    </p>
{% endblock %}

这是展示购物车详情的模板,包含了一个表格用于展示具体商品。用户可以通过表单修改之中的数量,并将其发送至cart_add视图。还提供了一个删除链接供用户删除商品。

2.5.3添加商品至购物车

需要修改商品详情页,增加一个Add to Cart按钮。编辑shop应用的views.py文件,把CartAddProductForm添加到product_detail视图中:

from cart.forms import CartAddProductForm

def product_detail(request, id, slug):
    product = get_object_or_404(Product, id=id, slug=slug, available=True)
    cart_product_form = CartAddProductForm()
    return render(request, 'shop/product/detail.html', {'product': product, 'cart_product_form': cart_product_form})

编辑对应的shop/templates/shop/product/detail.html模板,在展示商品价格之后添加如下内容:

<p class="price">${{ product.price }}</p>
<form action="{% url 'cart:cart_add' product.id %}" method="post">
    {{ cart_product_form }}
    {% csrf_token %}
    <input type="submit" value="Add to cart">
</form>
{{ product.description|linebreaks }}

启动站点,到http://127.0.0.1:8000/,进入任意一个商品的详情页,可以看到商品详情页内增加了按钮,如下图:

选择一个数量,然后点击Add to cart按钮,即可购物车详情界面,如下图:

2.5.4更新商品数量

当用户在浏览购物车详情时,在下订单前很可能会修改购物车的中商品的数量,我们必须允许用户在购物车详情页修改数量。

编辑cart应用中的views.py文件,修改其中的cart_detail视图:

def cart_detail(request):
    cart = Cart(request)
    for item in cart:
        item['update_quantity_form'] = CartAddProductForm(initial={'quantity': item['quantity'], 'update': True})
    return render(request, 'cart/detail.html', {'cart': cart})

这个视图为每个购物车的商品对象添加了一个CartAddProductForm对象,这个表单使用当前数量初始化,然后将update字段设置为True,这样在提交表单时,当前的数字直接覆盖原数字。

编辑cart应用的cart/detail.html模板,找到下边这行

<td>{{ item.quantity }}</td>

将其替换成:

<td>
    <form action="{% url 'cart:cart_add' product.id %}" method="post">
        {{ item.update_quantity_form.quantity }}
        {{ item.update_quantity_form.update }}
        <input type="submit" value="Update">
        {% csrf_token %}
    </form>
</td>

之后启动站点,到http://127.0.0.1:8000/cart/,可以看到如下所示:

修改数量然后点击Update按钮来测试新的功能,还可以尝试从购物车中删除商品。

2.6创建购物车上下文处理器

你可能在实际的电商网站中会注意到,购物车的详细情况一直显示在页面上方的导航部分,在购物车为空的时候显示特殊的为空的字样,如果购物车中有商品,则会显示数量或者其他内容。这种展示购物车的方法与之前编写的处理购物车的视图没有关系,因此我们可以通过创建一个上下文处理器,将购物车对象作为request对象的一个属性,而不用去管是不是通过视图操作。

2.6.1上下文处理器

Django中的上下文管理器,就是能够接受一个request请求对象作为参数,返回一个要添加到request上下文的字典的Python函数。

当默认通过startproject启动一个项目的时候,settings.py中的TEMPLATES设置中的conetext_processors部分,就是给模板附加上下文的上下文处理器,有这么几个:

除此之外,django还启用了django.template.context_processors.csrf来防止跨站请求攻击。这个组件没有写在settings.py里,强制启用,无法进行设置和关闭。有关所有上下文管理器的详情请参见https://docs.djangoproject.com/en/2.0/ref/templates/api/#built-in-template-context-processors

2.6.2将购物车设置到request上下文中

现在我们就来设置一个自定义上下文处理器,以在所有模板内访问购物车对象。

cart应用内新建一个context_processors.py文件,同视图,模板以及其他内容一样,django内的程序可以写在应用内的任何地方,但为了结构良好,将其单独写成一个文件:

from .cart import Cart
def cart(request):
    return {'cart': Cart(request)}

Django规定的上下文处理器,就是一个函数,接受request请求作为参数,然后返回一个字典。这个字典的键值对被RequestContext设置为所有模板都可以使用的变量及对应的值。在我们的上下文处理器中,我们使用request对象初始化了cart对象

之后在settings.py里将我们的自定义上下文处理器加到TEMPLATES设置中:

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, 'templates')]
        ,
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                ......
                'cart.context_processors.cart'
            ],
        },
    },
]

定义了上下文管理器之后,只要一个模板被RequestContext渲染,上下文处理器就会被执行然后附加上变量名cart

所有使用RequestContext的请求过程中都会执行上下文处理器。对于不是每个模板都需要的变量,一般情况下首先考虑的是使用自定义模板标签,特别是涉及到数据库查询的变量,否则会极大的影响网站的效率。

修改base.html,找到下面这部分:

<div class="cart">
Your cart is empty.
</div>

将其修改成:

<div class="cart">
    {% with total_items=cart|length %}
        {% if cart|length > 0 %}
            Your cart:
            <a href="{% url 'cart:cart_detail' %}">{{ total_items }} item{{ total_items|pluralize }},
            ${{ cart.get_total_price }}
            </a>
        {% else %}
            Your cart is empty.
        {% endif %}
    {% endwith %}
</div>

启动站点,到http://127.0.0.1:8000/,添加一些商品到购物车,在网站的标题部分可以显示出购物车的信息:

3生成客户订单

当用户准备对一个购物车内的商品进行结账的时候,需要生成一个订单数据保存到数据库中。订单必须保存用户信息和用户所购买的商品信息。

为了实现订单功能,新创建一个订单应用:

python manage.py startapp orders

然后在settings.py中的INSTALLED_APPS中进行激活:

INSTALLED_APPS = [
    # ...
    'orders.apps.OrdersConfig',
]

3.1创建订单模型

我们用一个模型存储订单的详情,然后再用一个模型保存订单内的商品信息,包括价格和数量。编辑orders应用的models.py文件:

from django.db import models
from shop.models import Product


class Order(models.Model):
    first_name = models.CharField(max_length=50)
    last_name = models.CharField(max_length=50)
    email = models.EmailField()
    address = models.CharField(max_length=250)
    postal_code = models.CharField(max_length=20)
    city = models.CharField(max_length=100)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)
    paid = models.BooleanField(default=False)

    class Meta:
        ordering = ('-created',)

    def __str__(self):
        return 'Order {}'.format(self.id)

    def get_total_cost(self):
        return sum(item.get_cost() for item in self.items.all())


class OrderItem(models.Model):
    order = models.ForeignKey(Order, related_name='items', on_delete=models.CASCADE)
    product = models.ForeignKey(Product, related_name='order_items', on_delete=models.CASCADE)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    quantity = models.PositiveIntegerField(default=1)

    def __str__(self):
        return '{}'.format(self.id)

    def get_cost(self):
        return self.price * self.quantity

Order模型包含一些存储用户基础信息的字段,以及一个是否支付的布尔字段paid。稍后将在支付系统中使用该字段区分订单是否已经付款。还定义了一个获得总金额的方法get_total_cost(),通过该方法可以获得当前订单的总金额。

OrderItem存储了生成订单时候的价格和数量。然后定义了一个get_cost()方法,返回当前商品的总价。

之后执行数据迁移,过程不再赘述。

3.2将订单模型加入管理后台

编辑orders应用的admin.py文件:

from django.contrib import admin
from .models import Order, OrderItem


class OrderItemInline(admin.TabularInline):
    model = OrderItem
    raw_id_fields = ['product']


@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ['id', 'first_name', 'last_name', 'email',
                    'address', 'postal_code', 'city', 'paid',
                    'created', 'updated']
    list_filter = ['paid', 'created', 'updated']
    inlines = [OrderItemInline]

我们让OrderItem类继承了admin.TabularInline类,然后在OrderAdmin类中使用了inlines参数指定OrderItemInline,通过该设置,可以将一个模型显示在相关联的另外一个模型的编辑页面中。

启动站点到http://127.0.0.1:8000/admin/orders/order/add/,可以看到如下的页面:

3.3创建客户订单视图和模板

在用户提交订单的时候,我们需要用刚创建的订单模型来保存用户当时购物车内的信息。创建一个新的订单的步骤如下:

  1. 提供一个表单供用户填写
  2. 根据用户填写的内容生成一个新Order类实例,然后将购物车中的商品放入OrderItem实例中并与Order实例建立外键关系
  3. 清理全部购物车内容,然后重定向用户到一个操作成功页面。

首先利用内置表单功能建立订单表单,在orders应用中新建forms.py文件并添加如下代码:

from django import forms
from .models import Order

class OrderCreateForm(forms.ModelForm):
    class Meta:
        model = Order
        fields = ['first_name', 'last_name', 'email', 'address', 'postal_code', 'city']

采用内置的模型表单创建对应order对象的表单,现在要建立视图来控制表单,编辑orders应用中的views.py

from django.shortcuts import render
from .models import OrderItem
from .forms import OrderCreateForm
from cart.cart import Cart

def order_create(request):
    cart = Cart(request)
    if request.method == "POST":
        form = OrderCreateForm(request.POST)
        if form.is_valid():
            order = form.save()
            for item in cart:
                OrderItem.objects.create(order=order, product=item['product'], price=item['price'],
                                         quantity=item['quantity'])
            # 成功生成OrderItem之后清除购物车
            cart.clear()
            return render(request, 'orders/order/created.html', {'order': order})

    else:
        form = OrderCreateForm()
    return render(request, 'orders/order/create.html', {'cart': cart, 'form': form})

在这个order_create视图中,我们首先通过cart = Cart(request)获取当前购物车对象;之后根据HTTP请求种类的不同,视图进行以下工作:

orders应用里建立urls.py作为二级路由:

from django.urls import path
from . import views

app_name = 'orders'

urlpatterns = [
    path('create/', views.order_create, name='order_create'),
]

配置好了order_create视图的路由,再配置myshop项目的根urls.py文件,在shop.urls之前增加下边这条:

    path('orders/',include('orders.urls', namespace='orders')),

编辑购物车详情页cart/detail.html,找到下边这行:

<a href="#" class="button">Checkout</a>

将这个结账按钮的链接修改为order_create视图的URL:

<a href="{% url 'orders:order_create' %}" class="button">Checkout</a>

用户现在可以通过购物车详情页来提交订单,我们要为订单页制作模板,在orders应用下建立如下文件和目录结构:

templates/
    orders/
        order/
            create.html
            created.html

编辑确认订单的页面orders/order/create.html,添加如下代码:

{% extends 'shop/base.html' %}

{% block title %}
Checkout
{% endblock %}

{% block content %}
    <h1>Checkout</h1>

    <div class="order-info">
        <h3>Your order</h3>
        <ul>
            {% for item in cart %}
            <li>
                {{ item.quantity }} x {{ item.product.name }}
                <span>${{ item.total_price }}</span>
            </li>
            {% endfor %}
        </ul>
        <p>Total: ${{ cart.get_total_price }}</p>
    </div>

    <form action="." method="post" class="order-form" novalidate>
        {{ form.as_p }}
        <p><input type="submit" value="Place order"></p>
        {% csrf_token %}
    </form>
{% endblock %}

这个模板,展示购物车内的商品和总价,之后提供空白表单用于提交订单。

再来编辑订单提交成功后跳转到的页面orders/order/created.html

{% extends 'shop/base.html' %}

{% block title %}
Thank you
{% endblock %}

{% block content %}
    <h1>Thank you</h1>
    <p>Your order has been successfully completed. Your order number is <strong>{{ order.id }}</strong>.</p>
{% endblock %}

这是订单成功页面。启动站点,添加一些商品到购物车中,然后在购物车详情页面中点击CHECKOUT按钮,之后可以看到如下页面:

填写表单然后点击Place order按钮,订单被创建,然后重定向至创建成功页面:

现在可以到管理后台去看一看相关的信息了。

4使用Celery启动异步任务

在一个视图内执行的所有操作,都会影响到响应时间。很多情况下,尤其视图中有一些非常耗时或者可能会失败,需要重试的操作,我们希望尽快给用户先返回一个响应而不是等到执行结束,而让服务器去继续异步执行这些任务。例如:很多视频分享网站允许用户上传视频,在上传成功之后服务器需花费一定时间转码,这个时候会先返回一个响应告知用户视频已经成功上传,正在进行转码,然后异步进行转码。还一个例子是向用户发送邮件。如果站点中有一个视图的操作是发送邮件,SMTP连接很可能失败或者速度比较慢,这个时候采用异步的方式就能有效的避免阻塞。

Celery是一个分布式任务队列,采取异步的方式同时执行大量的操作,支持实施操作和计划任务,可以方便的批量创建异步任务并且执行,也可以设定为计划执行。Celery的文档在http://docs.celeryproject.org/en/latest/index.html

4.1安装Celery

通过pip安装Celery:

pip install celery==4.1.0

Celery需要一个消息代理程序来处理外部的请求,这个代理把要处理的请求发送到Celery worker,也就是实际处理任务的模块。所以还需要安装一个消息代理程序:

4.2安装RabbitMQ

Celery的消息代理程序有很多选择,Redis数据库也可以作为Celery的消息代理程序。这里我们使用RabbitMQ,因为它是Celery官方推荐的消息代理程序。

如果是Linux系统,通过如下命令安装RabbitMQ:

apt-get install rabbitmq

如果使用macOS X或者Windows,可以在https://www.rabbitmq.com/download.html下载RabbitMQ。

安装之后使用下列命令启动RabbitMQ服务:

rabbitmq-server

之后会看到:

Starting broker... completed with 10 plugins.

就说明RabbitMQ已经就绪,等待接受消息。

译者注:Windows下安装RabbitMQ,必须先安装Erlong OPT平台,然后安装从官网下载回来的RabbitMQ windows installer。之后需要手工把Erlong安装目录下的bin目录和RabbitMQ安装目录下的sbin目录设置到PATH中。之后安装参见这里

4.3在项目中集成Celery

需要为项目使用的Celery实例进行一些配置,在settings.py文件的相同目录下创建celery.py文件:

import os
from celery import Celery

# 为celery程序设置环境为当前项目的环境
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myshop.settings')

app = Celery('myshop')

app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()

这段程序解释如下:

  1. 导入DJANGO_SETTINGS_MODULE环境变量,为Celery命令行程序创造运行环境。
  2. 实例化一个app对象,是一个Celery程序实例
  3. 调用config_from_object()方法,从我们项目的设置文件中读取环境设置。namespace属性指定了在我们的settings.py文件中,所有和Celery相关的配置都以CELERY开头,例如CELERY_BROKER_URL
  4. 调用autodiscover_tasks(),让Celery自动发现所有的异步任务。Celery会在每个INSTALLED_APPS中列出的应用中寻找task.py文件,在里边寻找定义好的异步任务然后执行。

还需要在项目的__init__.py文件中导入celery模块,以让项目启动时Celery就运行,编辑myshop/__init__.py

# import celery
from .celery import app as celery_app

现在就可以为应用启动异步任务了。

CELERY_ALWAYS_EAGER设置可以让Celery在本地以同步的方式直接执行任务,而不会去把任务加到队列中。这常用来进行测试或者检查Celery的配置是否正确。

4.4为应用添加异步任务

我们准备在用户提交订单的时候异步发送邮件。一般的做法是在应用目录下建立一个task模块专门用于编写异步任务,在orders应用下建立task.py文件,添加如下代码:

from celery import task
from django.core.mail import send_mail
from .models import Order

@task
def order_created(order_id):
    """
    当一个订单创建完成后发送邮件通知给用户
    """

    order = Order.objects.get(id=order_id)
    subject = 'Order {}'.format(order.id)
    message = 'Dear {},\n\nYou have successfully placed an order. Your order id is {}.'.format(order.first_name,
                                                                                               order_id)
    mail_sent = send_mail(subject, message, 'lee0709@vip.sina.com', [order.email])
    print(mail_sent, type(mail_sent))
    return mail_sent

order_created函数通过装饰器@task定义为异步任务,可以看到,只要用@task装饰就可以把一个函数变成Celery异步任务。这里我们给异步函数传入order_id,推荐仅传入ID,让异步任务启动的时候再去检索数据库。最后拼接好标题和正文后使用send_mail()发送邮件。

在第二章已经学习过如何发送邮件,如果没有SMTP服务器,在settings.py里将邮件配置为打印到控制台上:

EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

在实际应用中,除了耗时比较大的功能之外,还可以将其他容易失败需要重试的功能,即使耗时较短,也推荐设置为异步任务。

设置好了异步任务之后,还需要修改原来的视图order_created,以便在订单完成的时候,调用order_created异步函数。编辑orders应用的views.py文件:

from .task import order_created

def order_create(request):
    #......
    if request.method == "POST":
        #......
        if form.is_valid():
            #......
            cart.clear()
            # 启动异步任务
            order_created.delay(order.id)
        #......

调用delay()方法即表示异步执行该任务,任务会被加入队列然后交给执行程序执行。

启动另外一个shell(必须是导入了当前环境的命令行窗口,比如Pycharm中启动的terminal),使用如下命令启动Celery worker:

celery -A myshop worker -l info

现在Celery worker已经启动并且准备处理任务。启动站点,然后添加一些商品到购物车,提交订单。在启动了Celery worker的窗口应该能看到类似下边的输出:

[2017-12-17 17:43:11,462: INFO/MainProcess] Received task:
orders.tasks.order_created[e990ddae-2e30-4e36-b0e4-78bbd4f2738e]
[2017-12-17 17:43:11,685: INFO/ForkPoolWorker-4] Task
orders.tasks.order_created[e990ddae-2e30-4e36-b0e4-78bbd4f2738e] succeeded in
0.22019841300789267s: 1

表示任务已经被执行,应该可以收到邮件了。

译者注:Windows平台下,在发送邮件的时候,有可能出现错误信息如下:

not enough values to unpack (expected 3, got 0)

这是因为Celery 4.x 在win10版本下运行存在问题,解决方案为:先安装Python的eventlet模块:

pip install eventlet

然后在启动Celery worker的时候,加上参数 -P eventlet,命令行如下:

celery -A myshop worker -l info -P eventlet

即可解决该错误。在linux下应该不会发生该错误。参考Celery项目在 Github 上的问题:Unable to run tasks under Windows #4081

4.5监控Celery

如果想要监控异步任务的执行情况,可以安装Python的FLower模块:

pip install flower==0.9.2

之后在新的终端窗口输入:

celery -A myshop flower

之后在浏览器中打开http://localhost:5555/dashboard,即可看到图形化监控的Celery情况:

可以在https://flower.readthedocs.io/查看Flower的文档。

总结

这一章里创建了一个基础的电商网站。为网站创建了商品品类和详情展示,通过session创建了购物车应用。实现了一个自定义的上下文处理器用于将购物车对象附加到所有模板上,还实现了创建订单的功能。最后还学习了使用Celery启动异步任务。

在下一章将学习集成支付网关,为管理后台增加自定义操作,将数据导出为CSV格式,以及动态的生成PDF文件。