1. 程式人生 > 其它 >Django2實戰示例 第七章 建立電商網站

Django2實戰示例 第七章 建立電商網站

第七章 建立電商網站

在上一章裡,建立了使用者關注系統和行為流應用,還學習了使用Django的訊號功能與使用Redis資料庫儲存圖片瀏覽次數和排名。這一章將學習如何建立一個基礎的電商網站。本章將學習建立商品品類目錄,通過session實現購物車功能。還將學習建立自定義上下文管理器和使用Celery執行非同步任務。

本章的要點有:

  • 建立商品品類目錄
  • 使用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模型的欄位如下:

  • category:關聯到Category模型的外來鍵。這是一個多對一關係,一個商品必定屬於一個品類,一個品類包含多個商品。
  • name:商品名稱。
  • slug:商品簡稱,用於建立規範化URL。
  • image:可選的商品圖片。
  • description:可選的商品圖片。
  • price:該欄位使用了Python的decimal.Decimal類,用於儲存商品的金額,通過max_digits設定總位數,decimal_places=2設定小數位數。
  • availble:布林值,表示商品是否可用,可以用於切換該商品是否可以購買。
  • created:記錄商品物件建立的時間。
  • updated:記錄商品物件最後更新的時間。

這裡需要特別說明的是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, availbable=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資料的方法:

  • Database sessions:session資料存放於資料庫中,為預設設定,即將session資料存放到settings.py中的DATABASES設定中的資料庫內。
  • File-based sessions:儲存在一個具體的檔案中
  • Cached sessions:基於快取的session儲存,使用Django的快取系統,可以通過CACHES設定快取後端。這種情況下效率最高。
  • Cached database sessions:先存到快取再持久化到資料庫中。取資料時如果快取內無資料,再從資料庫中取。
  • Cookie-based sessions:基於cookie的方式,session資料存放在cookie中。

為了提高效能,使用基於快取的session是好的選擇。Django直接支援基於Memcached的快取和如Redis的第三方快取後端。

還有其他一系列的session設定,以下是一些主要的設定:

  • SESSION_COOKIE_AGE:session過期時間,為秒數,預設為1209600秒,即兩個星期。
  • SESSION_COOKIE_DOMAIN:預設為None,設定為某個域名可以啟用跨域cookie。
  • SESSION_COOKIE_SECURE:布林值,預設為False,表示是否只允許HTTPS連線下使用session
  • SESSION_EXPIRE_AT_BROWSER_CLOSE:布林值,預設為False,表示是否一旦瀏覽器關閉,session就失效
  • SESSION_SAVE_EVERY_REQUEST:布林值,預設為False,設定為True表示每次HTTP請求都會更新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序列化,用於存放購物車資料。購物車中必須包含如下內容:

  • Product物件的ID
  • 商品的數量
  • 商品的單位價格

由於商品的價格會變化,我們在將商品加入購物車的同時儲存當時商品的價格,如果商品價格之後再變動,也不進行處理。

現在需要實現建立購物車和為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):
        """
        初始化購物車物件
        """
        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()方法接受以下引數:

  • product:要向購物車內新增或更新的product物件
  • quantity:商品數量,為整數,預設值為1
  • update_quantity:布林值,為True表示要將商品數量更新為quantity引數的值,為False表示將當前數量增加quantity引數的值。

我們把商品的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表單包含如下兩個欄位:

  • quantity:限制使用者選擇的數量為1-20個。使用TypedChoiceField欄位,並且設定coerce=int,將輸入轉換為整型欄位。
  • update:用於指定當前數量是增加到原有數量(False)上還是替代原有數量(True),把這個欄位設定為HiddenInput,因為我們不需要使用者看到這個欄位。

建立向購物車中新增商品的檢視,編寫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_detailURL,稍後我們會來編寫cart_detailURL。

再來編寫刪除商品的檢視,在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_detailURL。

還需要一個展示購物車詳情的檢視,繼續在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.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裡,強制啟用,無法進行設定和關閉。有關所有上下文管理器的詳情請參見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請求種類的不同,檢視進行以下工作:

  • GET請求:初始化空白的OrderCreateForm,並且渲染orders/order/created.html頁面。
  • POST請求:通過POST請求中的資料生成表單並且驗證,驗證通過之後執行order = form.save()建立新訂單物件並寫入資料庫;然後遍歷購物車的所有商品,對每一種商品建立一個OrderItem物件並存入資料庫。最後清空購物車,渲染orders/order/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'),
]

配置好了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/__inti__.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, '[email protected]', [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檔案。