建立电商网站项目
在之前的章节,已经建立过了博客网站和社交网站的雏形,在今后建立站点和使用站点的时候,大概也会知道网站是如何实现该功能的。现在几种流行的网站,除了以博客为代表的内容管理网站,社交网站,还有电商网站,提供内容服务的在线教学网站等。
现在就要开始建立本书的第三个项目,电商网站。电商网站是目前非常流行的网站形式,其核心内容是如何处理商品列表,购物车和用户订单这些功能,以及集成支付功能。一般电商网站,是集成功能最多也最复杂的网站。
本章的要点有:
- 建立产品目录
- 使用session建立购物车
- 管理用户订单
- 使用Celery向用户发送异步通知
建立商品展示系统
在建立电商网站前,还是要思考一下电商网站的核心内容。用户会通过浏览商品页面,将一些商品加入购物车,然后在购物车中编辑,留下自己需要的商品,最后通过购物车去下订单。将这些过程拆分一下,就可以得到要做的事情:
- 建立商品清单数据类,加入到管理后台,展示商品清单
- 建立购物车系统,核心功能是在用户浏览网站的时候保存他们的选择
- 建立提交订单的页面
- 订单提交成功后异步发送邮件给用户
明确了任务之后,就开启一个新项目叫myshop,然后启动一个应用叫shop,将应用添加到settings.py中。依然推荐使用virtual env 建立虚拟环境。
建立商品数据类
对于商品来讲,参考一下流行的电商网站,可以发现商品需要属于一个品类,在那个品类之下,每个商品至少要有名称,可选的描述,可选的商品图片,价格,是否可用等数据。为此建立两个表,一个是商品品类,一个是商品,编辑shop应用下的model.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
需要解释的地方有:
- 处理金额相关的数据时,一定要使用DecimalField而不是FloadField,前者在Python 中对应Decimal类,后者就是普通双精度浮点。采用前者不会出现小数计算错误。
- Meta类中的index_together属性,表示联合索引。我们打算同时通过id和slug来索引商品,所以编制了联合索引,可以加速查询。联合索引是数据库中的概念。
- 由于存在ImageField,如果启用了虚拟环境,在进行migrate系列操作之前,还需要安装Python的Pillow模块(推荐5.1版)
之后可以进行makemigration 和 migrate完成数据表建立。
将两个数据模型注册到管理后台中
对于重要的数据类,一般都要加到管理后台中去方便操作。这里还要提一下的是,第三方数据库一般也有图形化的界面,比如用于MySQL系列的NaviCat,用于PostgreSQL的PgAdmin软件,结合这些软件和Django的管理后台,能够非常方便的直接在后端操作数据库。
在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表示用一个字段来生成另外一个字段,这里设置为使用name字段生成slug,这样可以方便的生成slug。
- list_editable属性表示其中的字段可以在列表页直接进行修改,就不用一个个点进去修改了。
- list_editable属性中的所有字段都必须包含在list_display属性中
之后不要忘记建立超级用户。之后登陆管理后台,添加一些商品品类和具体商品,可以看到如下的页面:
这里有个问题是图片上的stock字段是哪里来的?只能先继续往下看了。
建立商品品类视图
在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})
这个视图额外带一个默认为None的参数,说明我们之后要通过URL来传参数和自定义get_absolute_url方法。业务逻辑是先设置品类名为None,然后获得所有的商品。如果没有传参数,就把所有品类和可用商品传给模板。如果传入了具体的品类,就将该品类,全部品类列表和该品类的商品发给模板。
由于我们比较熟练了,就不用先建立模板了,可以直接再编写一个展示商品详情的视图:
def product_detail(request, id, slug): product = get_object_or_404(Product, id=id, slug=slug, availbable=True) return render(request, 'shop/product/detail.html', {'product': product})
两个视图函数都带了参数,在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使用id和slug来进行参数传递。
我们这是shop应用的二级路由,然后就需要编写项目的一级路由:
from django.contrib import admin from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), path('', include('shop.urls', namespace='shop')), ]
这个配置也很熟悉了,这里提一下就是include的二级路由的命名空间,在shop应用的urls.py里需要写上app_name = 'shop'
。
既然使用了URL传参数给视图函数,很显然就需要编写两个数据类的get_absolute_url方法了,在shop应用的models.py里编辑:
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])
就像之前做过很多次的这样,采用reverse反向解析并且将当前对象的参数传入来返回一个URL地址,就构建了每个对象生成URL地址的方法。
编写商品模板
与之前两个项目类似,在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>
BASE模板分为了三大部分:标题,购物车和内容。看到CSS就要想到从随书源码中将CSS文件COPY过来。顺便把同一个static目录下的img目录也拷贝过来,里边是默认的没有图片商品在页面上展示的图片。
然后编写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 %}
这个模板分为上下两部分,一个是侧边栏,一个是内容部分。侧边栏通过category变量判断具体品类存在与否,不存在就显示所有品类和链接,存在就切换一个CSS类selected。内容部分也判断具体品类存在与否,不存在就展示全部商品,存在就只展示该类的商品。
如果商品没有图片,就展示默认的图片。既然要存放图片,则需要在settings.py和urls各里配置一下:
# settings.py MEDIA_URL = '/media/' MEDIA_ROOT = os.path.join(BASE_DIR, 'media/') # main urls.py if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
完成了图片文件路径配置之后,添加一些商品和图片,然后启动站点,可以看到类似于下边的页面:
如果没有给商品上传图片,显示就是这样:
然后编写商品详情页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 %}
商品详情页面示例如下:
到这里就把商品类别和详情页面做完了,没有用到什么新东西,数据结构还比之前的社交网站项目要简单一些,下边来实现购物车功能。
通过session功能建立购物车系统
在开始做之前依然要思考一下,常见网站的购物车,在浏览整个网站,离开又返回的时候,购物车中的内容都可以保持原来的状态,那么其中的数据一定也是保存在某处。
购物车还一个突出的特点就是,如果将购物车中的商品下了订单,该商品就会从购物车中消失。
将购物车抽象成一个对象的话,这个对象很像一个数据库,挑选商品的时候往其中加入商品以及数量,下订单的时候减少商品和对应的数量。那么这个数据库应该保存在哪里呢?
答案就是session里,session很适合用来存储当前用户的一些小数据。
了解Django的session模块
Django 的session模块在默认建立一个应用的时候,已经包含在settings.py里启用的中间件'django.contrib.sessions.middleware.SessionMiddleware'
中。这个中间件会让所有的request都具有session属性。
request.session可以把它想象成是一个Python字典,可以存储任何Python的数据并且序列化我Json格式。简单的操作如下:
# 向session里存数据 request.session['foo'] = 'bar' # 从session里取数据 request.session.get('foo') # 删除session中的键值对 del request.session['foo']
session的详细介绍和命令可以参考本站的django 中使用session,该文章对应的版本是django 1.11.14,但与2.1没有区别。
这里需要注意一点的是,当用户先浏览站点再登录的时候,原来作为匿名用户的session会被一个新的认证用户的session所替代。很多电商网站有这样的功能:在未登录的情况下将商品加入购物车,在购买的时候进行登录,则购物车中的内容还在。这是因为后端在登录的时候,将匿名用户的session里的购物车数据复制到了登录用户的session中。
session模块的设置
django中可以配置session模块的一些参数,其中最重要的是 SESSION_ENGINE
设置,设置session具体存储在何处。默认情况下,django.contrib.session模块将session数据保存在默认生成的django_session表中。
Django提供了如下几种类型的session可供选择:
- 存放于数据库中的session:默认设置,即将session数据存放到settings.py中的DATABASES设置中的数据库内。
- 基于文件的session:保存在一个具体的文件中
- 缓存的session:存储在django 的缓存系统中,可以通过CACHES设置缓存。这种情况下速度最快。(怀念Redis了吗?)
- 缓存与数据库结合的方式:先存到缓存再持久化到数据库中。取数据时如果缓存内无数据,就从数据库中取。
- Cookie方式:session存放在cookie中。
所有session相关的设置需要写在settings.py中,主要的设置有:
- SESSION_COOKIE_AGE 为秒数,默认为1209600(两个星期)
- SESSION_COOKIE_DOMAIN 默认是none,设置为某个主机名可以启用跨域cookie。
- SESSION_COOKIE_SECURE 布尔值,默认为False,设置为True表示只允许HTTPS下使用session
- SESSION_EXPIRE_AT_BROWSER_CLOSE 布尔值,默认为False,设置为True表示浏览器关闭就失效
- SESSION_SAVE_EVERY_REQUEST 布尔值,默认为False,设置为True表示每次HTTP请求都会更新session,其中的过期时间相关设置也会一起更新。该项可以考虑修改成True。
详细内容当然还是要去看django的session 文档。
这里特别要提一下的就是session的过期控制。在之前做两个项目的时候应该也会注意到,当我登录之后关闭页面的时候,下次再打开页面,依然处于登录状态,这就是因为session保存了登录状态。
SESSION_EXPIRE_AT_BROWSER_CLOSE
默认为False,如果设置为True,则 SESSION_COOKIE_AGE
中的设置不起作用。
在需要延长session过期的情况下,可以使用request.session.set_expiry()
设置过期时间。
在session中存储购物车数据
存储购物车数据的关键有两个:一是确定存储内容,二是确定存储的方式。从前边session的介绍来看,存储方法打算采用JSON格式,而存储内容是:
- 商品的id字段数据
- 商品的数量
- 商品的单位价格
这里有个问题是,商品的价格会变化。这里为了简便,我们在将商品加入购物车的同时存储当时商品的价格,如果商品价格之后再变动,也不去管了。(当然这和普通的电商网站是有区别的)
然后我们需要来实现购物车的功能,在这之前需要更细致的分析一下业务逻辑:
- 先检查session中是否存在购物车键,如果存在说明当前用户已经使用了购物车,如果不存在,就新建一个购物车键。
- 用户每次HTTP请求,都要重复第一步。
- 之后根据用户提交的操作,向购物车内添加或者删除数据。
下边来实现功能。先到settings.py里新增一行:
CART_SESSION_ID = 'cart'
这就是我们的购物车键名称,由于session对于每个用户都通过中间件管理,所以可以在所有用户的session里都使用统一的这个名称。
然后新建一个应用专门来控制购物车,启动新应用 cart,然后加入到settings.py中。在cart应用目录下新建cart.py然后编写:
from decimal import Decimal from django.conf import settings from shop.models import Product class Cart(object): def __init__(self): """ Initialize the cart. """ self.session = request.session cart = self.session.get(settings.CART_SESSION_ID) if not cart: cart = self.session[settings.CART_SESSION_ID] = {} self.cart =cart
这里先编写了一个用来管理cart的类,考虑到每个用户都会有session,可以编写一个类,对应每个用户就实例化一个管理类。目前这个类只编写了初始化函数,里边的逻辑是判断request中是否有cart键,有就取过来,没有就新建一个空白键cart。
初始化了cart之后,下一步就是对cart进行修改。我们打算把商品ID作为键名,将数量和价格作为对应的值。这样可以保证不会重复添加相同的商品,同一个商品只会有一个唯一的键。
继续编写Cart类里的修改购物车功能:
class Cart(object): def add(self, product, quantity=1, update_quantity=False): """ 向购物车中增加商品或者更新购物车中的数量 :param product: Product实例对象 :param quantity: 增加商品的数量,为整数,默认为1 :param update_quantity: False 表示在原有数量上增加,True表示覆盖原有数量 :return: None """ 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和save两个方法。add的功能在注释里已经基本写清楚了,具有设置新数量或增加数量的功能。对于每个session,建立一个cart键,然后其中的套一个product_id键,然后又有两个键price和quantity。一共是三层字典嵌套。
还需要编写从购物车中去掉商品的方法,逻辑比较简单,判断要删除的东西存在于购物车里,就删掉:
class Cart: def remove(self, product): """ 从购物车中删除商品 :param product: 要删除的Product实例 :return: None """ product_id = str(product.id) if product_id in self.cart: del self.cart[product_id] self.save()
在后边展示购物车的界面中,很容易就想到需要遍历购物车内的所有商品。因此现在先写一个__iter()__方法,生成迭代器,供将for循环使用。
class Cart: def __iter__(self): """ 迭代所有购物车内的商品 :return: 迭代器对象 """ 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
这段代码的逻辑是:取到所有购物车内商品的QuerySet,然后对购物车对象进行了一个浅拷贝。浅拷贝之后的局部变量cart的第一级键不会再变化,但是其中嵌套的字典依然可以变化。原来我们购物车的结构是{'cart': {'product_id': {'quantity': quantity, 'price': price}}}
,并且里边都是字符串。但是现在需要迭代其中的商品,就不能简单的返回字符串。
在浅拷贝之后,给这个购物车新增加了product键对应Product实例。
之后用临时变量item返回每个对象的内容,把item键price的值设置为Decimal类型,然后新增了一个总价字段,最后用yield返回每个item。此时每个item的结构是这样的:{'quantity': quantity, 'price': new_price, 'product': product, 'total_price': total_price}
,可以想象,未来通过for循环遍历的时候,只需要将这几个键的内容取出来就可以了。
考虑到购物车还需要显示其中一共有几件商品,因此再写一个__len__()方法,以让我们的类可以通过内置方法len来显示长度,实现逻辑就是遍历所有购物车内商品的quantity然后求和:
class Cart: def __len__(self): """ 购物车内一共有几种商品 :return: INT """ 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()
这样就编写完了这个类。这里的知识其实和session没有很大的关系,主要还是Python中编写类的特殊方法的知识。
这里还要提一下的是,原书的代码定义Cart类的时候是 class Cart(object):
可能是作者为了兼容Python2的代码。这里没有按照原书的代码,而是统一按照Python3的新式类方法来编写。
建立购物车视图
建立完了管理购物车的类之后,就可以来编写视图了。购物车还记得最开始说类似一个小数据库,所以功能不外乎增删改查。视图的作用,就是将用户前端的增删改查动作获取到,然后调用购物车类来进行增删改查。
针对购物车类的功能,显然我们需要编写3个视图函数:
- 一个视图函数用来增加或者更新购物车内的商品和数量(增和改)
- 一个视图删除购物车内的商品(删)
- 一个视图显示购物车内的详细内容和总价(查)
现在就来编写这三个视图。首先是增加和更新商品的视图。
为了把商品增加到购物车,除了具体商品的实例肯定在页面中获取了之外,还必须要让用户选择增加的数量。因此先到cart应用中建立form.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)
这个表单包括两个字段,第一个字段是给用户进行选择数量,这里我们通过choices限定了用户选择的数量为1-20,使用了coerce=int表示将该字段的数据转换成整型。coerce参数只能使用在TypedChoiceField和TypedMultipleChoiceField上。
第二个字段update,用于确认用户是增加数量还是覆盖原来的数量。如果为True,则覆盖,如果为False则增加,这里用了HiddeInput就不想给用户看了。
有了表单以后来建立视图,编写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) # 取得POST表单对象 form = CartAddProductForm(request.POST) # 如果表单验证通过,调用cart类的add方法然后跳转到购物车详情页面 if form.is_valid(): cd = form.cleaned_data cart.add(product=product, quantity=cd['quantity'], update_quantity=cd['update']) return redirect('cart:cart_detail')
业务逻辑已经写在了注释里,这个视图的功能非常类似京东的商品详情页面,点击加入购物车的按钮,之后会跳转到另外一个界面,可以返回商品页或者查看购物车,这里我们就直接跳转到了购物车详情页。
再来编写删除商品的视图:
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')
删除方法很简单,继续来编写展示视图:
def cart_detail(request): cart = Cart(request) return render(request, 'cart/detail.html', {'cart': cart})
视图都编写好了之后,在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'), ]
在项目的一级路由内增加一条,将cart路径转发到cart应用的二级路由里:
path('cart/', include('cart.urls', namespace='cart')),
注意这一条路由需要增加在shop路径之前,因为shop路径被配置成默认路径,如果放在shop下边,则永远无法被匹配到。路由配置的时候,越严格的匹配越要往上放,最后用最宽泛的路径接着。
建立购物车相关模板
回想一下之前建立的base.html,其中为购物车留出了一行。此外,增加购物车的按钮应该出现在每个商品的详情页中,最后还要单独编写一个购物车清单页面。
建立:cart/templates/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 %}
虽然模板很长,但是逻辑很清晰,使用了一个表格展示购物车的内容。表头固定,表内的每一行通过迭代购物车中的每个item,展示对应的字段内容。还留出了删除该商品的链接。最后在表格底部展示购物车总价。
加入购物车的功能如前所述,需要修改商品详情页,注意我们为加入购物车建立了一个表单,所以先要修改product_detail视图函数:
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>
然后启动站点来实验一下相关功能,可以看到商品详情页内增加了新的功能:
点击增加到购物车之后,进入了购物车界面:
修改购物车中商品的数量
读者有过电商网站购物经验的话,就会知道我们的购物车很明显缺少一个功能,就是购物车页面修改商品的数量。这种修改与将商品加入购物车不同,修改的结果直接就是商品的最终数量。所以Cart.add方法里也提供了覆盖原来数量的功能。
需要实现这个功能,只需要在购物车详情视图和模板中做一个小修改即可,先来编辑cart应用里的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})
这个视图在每次进入购物车详情页面的时候,给所有的商品添加了一个键值对,值是initial里的数据初始化的CartAddProductForm实例。
为什么要这么做呢,其实就是为了区分当前的修改是来自购物车详情页面的,这里的修改都需要覆盖原数量。
回忆一下操作的流程。CartAddProductForm的update被我们以隐藏的形式埋在了商品详情页面里。其默认为空,提交给add方法的update参数就是空。所以在送到add方法里的时候,会将当前数量加上去。那么我们只需要在进入购物车详情页的时候,修改表单里的update字段的值为True就可以了。
然后修改网页上展示的那个quantity的部分,打开cart/templates/cart/detaii.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>
修改之后,就是将原来的数字部分改成了一个表单,表单的数据就是用视图函数中初始化的表单类的数据填充的,这样隐藏的update字段的值变成了True,只要是从这个页面提交给cart_add视图的表单,都会覆盖原来数量。
之后启动站点,到购物车详情页来,可以看到如下所示:
为购物车构建上下文处理器
所谓上下文,就是程序的外部变量集合。我们注意到这个时候还有一个地方的功能没有实现,那就是网站首页的购物车那一栏,依然没有变化。每个网站都会有一些可能需要在每个页面都使用的数据,这些东西就类似于全局变量。就像在前几章在模板里使用过的request.user,request.messages以及本章的request.session,并不需要我们给模板传入request对象和user对象等,直接在所有的视图和模板中都可以使用。
回忆一下前博客项目对base.html显示最多评论的文章是使用了自定义的template tag;社交网站的base.html的顶部,为了知道当前的内容以便应用CSS样式,每个视图都传了一个section变量。这都是一种技巧。更通用的做法就像社交网站base.html的消息部分,直接采用request.messages变量。
django中的上下文管理器,就是能够接受一个request请求对象作为参数,然后给request对象附加一个键值对的函数。当默认启动一个项目的时候,settings.py中的 TEMPLATES 设置中的conetext_processors
部分,就是给模板附加上下文的上下文管理器,有这么几个:
- django.template.context_processors.debug:这个上下文管理器附加了布尔类型的debug变量,以及sql_queries变量,表示请求中执行的SQL查询
- django.template.context_processors.request:这个上下文管理器设置了request变量
- django.contrib.auth.context_processors.auth:这个上下文管理器设置了user变量
- django.contrib.messages.context_processors.messages:这个上下文管理器设置了messages变量
除此之外,django还启用了django.template.context_processors.csrf来防止跨站请求攻击。这个组件没有写在settings.py里,但是是强制启用的,无法进行设置和关闭。有关所有上下文管理器的详情请参见官方文档。
现在我们就来设置一个自定义上下文管理器,原来每个视图都将cart变量传递给模板,以后就可以在所有模板内直接使用cart变量。
在cart应用内新建一个context_processors.py文件,同视图,模板以及其他内容一样,django内的程序可以写在应用内的任何地方,但为了结构良好,还是分离出各个组件比较好:
from .cart import Cart def cart(request): return {'mycart': Cart(request)}
有点简单是不是,django规定的上下文处理器,就是一个函数,接受request请求作为参数,然后返回一个键值对。键的名称就是未来在模板中可以使用的request.XXX名称。
之后在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' ], }, }, ]
定义了上下文管理器之后,只要一个模板被渲染,上下文管理器就会执行然后附加上变量名。值得注意的是,上下文管理器并不是必须的。如果上下文的内容涉及到数据库查询等比较耗时的操作,或者上下文管理器中的变量,并不是所有页面都需要,则一般情况下首先考虑的是使用自定义模板变量。否则会极大的影响网站的效率。
有了mycart变量之后,由于只是给request附加了初始化的Cart实例,所以对其他视图没有任何影响,base.html由于没有视图直接控制,就使用上下文变量比较方便,修改base.html:
{#修改<div class="cart">的内容#} <div class="cart"> {% with total_items=mycart|length %} {% if mycart|length > 0 %} Your cart: <a href="{% url 'cart:cart_detail' %}">{{ total_items }} items{{ total_items|pluralize }}, ${{ mycart.get_total_price }} </a> {% else %} Your cart is empty. {% endif %} {% endwith %} </div>
启动站点,可以发现现在购物车栏正常工作了:
这里需要说明一下的是:原书的上下文管理器的代码是:
from .cart import Cart def cart(request): return {'cart': Cart(request)}
由于所有的用到购物车界面的模板都继承了base.html,视图函数都传入了名叫cart的变量,base.html里也使用cart名称的变量,作者在这里把全局变量和传入的变量设置成一样,没有很好的体现出区分效果。故将上下文管理器和base.html中的变量名cart都修改为mycart,以体现出mycart和cart是两个来自于不同地方的变量。
用户订单功能
在刚才的购物车详情页面中,有一个Checkout 结账功能,目前还无法使用。现在要来制作用户订单功能。
在每个购物车生成订单的时候,需要把订单的数据保存到数据库中。一个订单应该包含用户以及要购买的商品等信息。
建立一个新的应用orders并在settings.py里配置好。我们将用这个应用来处理订单相关的功能。
建立订单数据模型
首先还是思考一下,在点击Checkout按钮的时候,购物车页面上有什么数据?主要的数据就是cart变量代表的购物车数据对象。光有商品还不够,肯定还需要知道当前的用户是谁。其实还有一个重要的数据就是该订单是否已经支付。Order模型就是围绕这些数据建立的,编辑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和OrderItem,Order模型用来存储订单号,和订单相关的用户信息,以及一个是否支付的布尔字段。稍后将在支付系统中将使用该字段。还定义了一个获得总金额的方法,以便方便的知道订单的总额以便支付。
OrderItem两个外键分别连接到商品表和订单表,然后还存储了生成订单时候的价格和数量。由于价格是会变化的,所以在订单中只存储生成订单时候的价格用于计算总付款金额。然后定义了一个get_cost方法。这个类看起来类似多对多关系的的中间表,但实际上发挥的是一对一的功能。因为无需再到Product中去查询金额。
执行过makemigrations和migrate之后,将这两个类加入到管理后台中,编辑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类。意思是将OrderItem类以内联(行显示)的状态包含在Order类中,显示在同一页。
这种注册模型的方式仅影响展示形式,不会影响models.py中定义的两个类之间的关系。
启动站点到 http://127.0.0.1:8000/admin/orders/order/add/ 可以看到如下的页面,更好的展示了订单与其中包含的商品之间的关系:
建立订单视图和模板
用过电商网站的读者应该知道,比如京东从购物车页面点击“去结算”按钮,会进入到一个结算页,供用户填写信息,选择地址,支付方式和配送方式等。这个页面实际上也是填写一个表单,再点提交才会真正生成订单让你付款。
类似的,我们生成一个新订单有如下步骤:
- 给用户提供一个表单供填写
- 根据用户填写的内容生成一个新Order类实例,然后将Cart中的商品放入OrderItem实例中并与Order实例建立外键关系
- 清理全部购物车内容,然后重定向用户到一个成功的页面或者进行其他动作。
首先就利用内置表单功能建立订单表单,在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']
采用内置的方法建立了包含这些字段的表单,现在针对这个表单要建立视图来控制表单,编辑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) # 表单验证通过就对购物车内每一条记录生成OrderItem中对应的一条记录 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})
整体的逻辑是,如果是POST请求而且表单验证通过,对购物车里每一个商品都在OrderItem表里生成对应的一行记录,之后删除购物车,并且不再像模板内cart变量了。(当然我们有上下文管理器,还是可以用)。
如果是POST请求但表单验证失败,返回原来表单数据,如果是GET请求,返回空白表单及购物车数据。注意,这里两个分支渲染的是两个不同的页面 create.html和 created.html。
视图有了下一步是配路由,先在orders应用里建立urls.py作为二级路由:
from django.urls import path from . import views app_name = 'orders' urlpatterns = [ path('create/', views.order_create, name='order_create'), ]
再配置主路由myshop/urls.py:
path('orders/',include('orders.urls', namespace='orders')),
注意这一行也要在转发到shop.urls那一行之前。
既然现在我们有了视图函数,那就可以修改购物车详情页cart\templates\cart\detail.html的CHECKOUT按钮的链接了:
<a href="{% url 'orders:order_create' %}" class="button">Checkout</a>
最后是来建立模板。在orders应用下建立templates/orders/order目录,然后在order目录内建立create.html和created.html,先来编辑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 %}
模板内容比较简单,展示购物车内的商品和总价,之后下边提供空白表单供填写。再来编辑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 %}
created.html就是一个成功页面。现在运行站点,加入一些商品到购物车中,然后点击checkout,可以看到如下页面:
填写表单然后点击Place order,可以看到成功页面:
至此就做完了订单功能。
使用Celery异步发送订单邮件给用户
如果读者有过在Amazon上购物的经历,可以知道很多电商网站在用户成功下订单后,会发送一封电子邮件到用户的邮箱,内容一般是告诉用户订单的内容。
为了发送邮件,先来了解一下异步的程序。我们在视图函数中所作的所有事情,都会影响视图函数的响应时间,在视图函数完成操作之前,某些URL或者网站内容可能不可用。对于比较耗时间的操作,这样可能会导致请求失败,或者需要用户重试。举个例子:很多视频分享网站允许用户上传视频,上传之后,服务器还需要花时间进行转码,如果在这个时候用户访问该视频,显然不可能允许用户直接访问资源地址,会导致访问失败。常用的做法是设置一个重试功能,在转码完成之前用户想要查看自己上传的视频,就返回一个请求说该视频尚未处理完毕。
如果我们在视图中调用发送邮件的程序,SMTP服务器很可能会连接失败或者花费比较长的时间,如果直接在视图函数内编写,则视图函数会阻塞到邮件发送完毕才会执行下一步程序。会导致页面的响应非常慢。如果能让发邮件和返回页面响应异步的进行,那么页面的响应速度就可以提高很多。读者可以回忆一下,我们在社交网站的用户验证部分建立了发送邮件重置密码的功能,点击发送到返回成功发送的速度是不是相比其他页面要慢很多。
Celery是一个分布式任务队列,采取异步的方式同时执行大量的操作,支持实施操作和计划任务,可以方便的批量创建异步任务并且执行。Celery的文档在http://docs.celeryproject.org/en/latest/index.html。
安装Celery
在django 中使用 Celery 需要在Python 中安装Celery模块:
pip install celery==4.1.0
作者推荐的是4.1版,在本文发布的时候celery最新版为4.2.1,之前已经体验过了taggit的低版本和Django 2.1冲突的问题,所以既然安装了Django 2.1,笔者没有按照作者推荐,而是安装celery的最新版。如果发现问题,踩坑捉虫的过程也是很有意思的。
Celery需要一个消息代理程序来处理外界的请求,这个代理把要处理的请求发送到Celery worker,也就是实际处理任务的模块。所以还需要安装一个消息代理程序:
安装RabbitMQ
Celery的消息代理程序有很多选择,Redis数据库也可以作为Celery的消息代理程序。这里我们使用RabbitMQ,因为它是Celery官方推荐的消息代理程序。
关于Celery,RabbitMQ以及Redis的相关内容,可以看这里的一篇文章。
RabbitMQ的下载地址:https://www.rabbitmq.com/download.html。
Linux下安装RabbitMQ:
apt-get install rabbitmq
之后启动服务:
rabbitmq-server
之后会看到:
Starting broker... completed with 10 plugins.就说明RabbitMQ已经就绪,等待接受消息。
Windows 下安装 RabbitMQ,必须先安装Erlong OPT平台,然后安装从官网下载回来的RabbitMQ windows installer。
然后把Erlong安装目录下的bin目录和RabbitMQ安装目录下的sbin目录设置到PATH中。之后安装参见这里。
将Celery 加入到项目中
确定好了本机的RabbitMQ服务正常运行之后,需要在django 中使用celery,在myshop/myshop/目录下新建一个文件celery.py:
import os from celery import Celery # 导入django的环境 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myshop.settings') app = Celery('myshop') app.config_from_object('django.conf:settings', namespace='CELERY') app.autodiscover_tasks()
这里的程序主要是和Celery相关,而不是django,解释如下:
- 首先导入django 的环境变量,为Celery命令行创造环境。如果读者使用Pycharm开发django项目,Pycharm中启动Python console,实际上已经默认执行了该语句,作用就是将当前命令行环境配置成django项目。
- 然后实例化了一个Celery对象
- 调用Celery对象的.config_from_object方法,从我们的设置文件中读取设置。Celery除了可以通过实例化简单使用外,还支持设置文件进行设置,从而比较健壮的使用Celery。同时提供了一个命名为CELERY,说明我们的设置文件里,所有和CELERY相关的设置都以"CELERY"开头。
- 最后我们调用.autodiscover_tasks(),让Celery自动发现所有的异步任务。Celery会在每个INSTALLED_APPS中列出的应用中寻找task.py文件,在里边寻找定义好的异步任务然后执行。
还需要在项目的__init__.py文件中导入celery模块,以让django运行的时候celery就运行,编辑myshop/__inti__.py:
import celery from .celery import app as celery_app
CELERY_ALWAYS_EAGER设置可以让celery在本地以同步的方式直接执行任务,而不会去把任务加到队列中。这常用来进行测试或者检查Celery的配置是否正确。
之后就可以来编辑异步任务了。
编写需要异步处理的任务
我们准备在用户提交订单的时候异步发送邮件。一般的做法是在应用目录下建立一个模块(task.py)用于专门的异步任务,在orders应用下建立task.py:
from celery import task from django.core.mail import send_mail from .models import Order @task def order_created(order_id): """ 当一个订单创建完成后发送邮件通知给用户 :param order_id: 订单编号 :return: mail_sent """ 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
使用celery模块中的@task 装饰器装饰想成为异步任务的函数。推荐只给异步函数传入ID,让其去检索数据库。拼接好标题和正文后发送邮件。
在之前已经学习过如何发送邮件,如果没有SMTP服务器,在settings.py里将邮件配置为打印到控制台上:
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
这里是将发送邮件这类耗时比较大的功能配置到为异步功能,在实际应用中,除了耗时比较大的功能之外,还可以将其他容易失败需要重试的功能,无论耗时长短,都可以设置为异步任务。
设置好了异步任务之后,还需要修改原来的视图order_created,以便在订单完成的时候,调用该异步函数。修改后的视图如下:
from django.shortcuts import render from .models import OrderItem from .forms import OrderCreateForm from cart.cart import Cart from .task import order_created def order_create(request): cart = Cart(request) if request.method == "POST": form = OrderCreateForm(request.POST) # 表单验证通过就对购物车内每一条记录生成OrderItem中对应的一条记录 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() # 成功完成订单后调用异步任务发送邮件 order_created.delay(order.id) return render(request, 'orders/order/created.html', {'order': order}) else: form = OrderCreateForm() return render(request, 'orders/order/create.html', {'cart': cart, 'form': form})
被装饰过的异步函数对象有一个delay方法,将order.id作为参数,order.id就会被传给我们自行编写的函数。当每次有用户完成订单的时候,这个异步的函数对象就会被加入到队列,然后等着被worker执行。
启动另外一个shell(必须是导入了当前环境的命令行窗口,比如Pycharm中启动的terminal),启动Celery worker,让工人等待干活:
celery -A myshop worker -l info
之后可以看到类似下边的输出,表示Celery worker 已经就绪:
[2018-09-28 20:51:13,985: INFO/MainProcess] Connected to amqp://guest:**@127.0.0.1:5672// [2018-09-28 20:51:13,999: INFO/MainProcess] mingle: searching for neighbors [2018-09-28 20:51:15,040: INFO/MainProcess] mingle: all alone [2018-09-28 20:51:15,086: INFO/MainProcess] pidbox: Connected to amqp://guest:**@127.0.0.1:5672//. [2018-09-28 20:42:04,641: INFO/SpawnPoolWorker-6] child process 16896 calling self.run() [2018-09-28 20:42:04,641: INFO/SpawnPoolWorker-1] child process 15652 calling self.run() [2018-09-28 20:42:04,673: INFO/SpawnPoolWorker-8] child process 18224 calling self.run() [2018-09-28 20:42:05,225: WARNING/MainProcess] django.py:200: UserWarning: Using settings.DEBUG leads to a memory leak, never use this setting in p roduction environments! warnings.warn('Using settings.DEBUG leads to a memory leak, never ' [2018-09-28 20:42:05,227: INFO/MainProcess] celery@Notebook ready.
可以看到Celery默认去连接了RabbitMQ的服务,然后显示使用guest:账号连接成功;之后提醒我们在生产环境下不要使用DEBUG,会导致内存泄露。
现在我们启动django站点,然后去实际提交一个订单。看到terminal窗口中出现:
[2018-09-28 20:51:37,255: INFO/MainProcess] Received task: orders.task.order_created[d9734042-2423-45cc-8109-a901f938bec4] [2018-09-28 20:51:42,895: WARNING/MainProcess] 1 [2018-09-28 20:51:42,896: WARNING/MainProcess] <class 'int'> [2018-09-28 20:51:42,896: INFO/MainProcess] Task orders.task.order_created[d9734042-2423-45cc-8109-a901f938bec4] succeeded in 5.64100000000326s: 1
检查邮件发现成功的完成了任务。如果注意看RabbitMQ的监控窗口,可以看到RabbitMQ的DeliverMessage会出现我们的任务,Queues中可以看到有一个Celery的运行中队列
注意,在发送邮件的时候,有可能出现错误信息如下:
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
使用Flower图形化模块监控Celery
如果想要监控异步任务的执行情况,可以安装Python 的FLower模块:
pip install flower==0.9.2
Flower库好像是本书中第二个从作者成书到翻译之间没有更新版本的库。Flower的项目地址是https://flower.readthedocs.io/
之后在新的终端窗口(如果是Pycharm,点terminal左上角的+)输入:
celery -A myshop flower
之后在浏览器中打开http://localhost:5555/dashboard,即可看到图形化监控的Celery情况:
总结
这一章里主要完成了四个任务,分别是建立基础的商品品类和明细展示页面,通过session建立购物车系统,建立提交订单系统,以及以发送邮件为例子介绍了在django 中使用异步任务。
主要的新知识有:
- 为字段建立联合索引,使用meta类的index_together属性
- 注册管理后台的list_edit属性,可以直接在列表页修改字段
- session模块的中间件与类似字典方式的使用
- 自定义类管理购物车和类似的小型数据
- 建立自定义上下文处理器,为项目内所有表单增加全局变量
- 上下文管理器与自定义template tag的取舍
- 注册管理后台的Inline模式,外键关联的表格显示在一页内的做法
- Celery与RabbitMQ在linux和windows下的配置
- Django内使用Celery的方法与异步功能的编写
- Win10中Celery运行错误的解决方法
- Flower模块图形化监控Celery状态