1. 程式人生 > 其它 >Django2實戰示例 第八章 管理支付與訂單

Django2實戰示例 第八章 管理支付與訂單

第八章 管理支付與訂單

上一章製作了一個帶有商品品類展示和購物車功能的電商網站雛形,同時也學到了如何使用Celery給專案增加非同步任務。本章將學習為網站整合支付閘道器以讓使用者通過信用卡付款,還將為管理後臺擴充套件兩項功能:將資料匯出為CSV以及生成PDF發票。

本章的主要內容有:

  • 整合支付閘道器到專案中
  • 將訂單資料匯出成CSV檔案
  • 為管理後臺建立自定義檢視
  • 動態生成PDF發票

1整合支付閘道器

支付閘道器是一種處理線上支付的網站或者程式,使用支付閘道器,就可以管理使用者的訂單,然後將支付過程交給一個可信賴且安全的第三方,而無需在我們自己的站點上處理支付資訊。

支付閘道器有很多可供選擇,我們將要整合的是叫做"Braintree"的支付閘道器。Braintree使用較為廣泛,是Uber和Airbnb的支付服務提供商。Braintree提供了一套API用於支援信用卡,PayPal,Android Pay和Apple Pay等支付方式,官方網站在

https://www.braintreepayments.com/

Braintree提供了很多整合的方法,最簡單的整合方式就是Drop-in整合,包含一個預先建立好的支付表單。但是為了自定義一些支付過程中的內容,這裡選擇使用高階的Hosted Field(欄位託管)方式進行整合。在https://developers.braintreepayments.com/guides/hosted-fields/overview/javascript/v3可以看到詳細的幫助文件。

支付表單中包含的信用卡號,CVV碼,過期日期等資訊必須要得到安全處理,Hosted Field整合方式將這些欄位展示給使用者的時候,在頁面中渲染的是一個iframe框架。我們可以來自定義該欄位的外觀,但必須要遵循

Payment Card Industry (PCI)安全支付的要求。由於可以修改外觀,使用者並不會注意到頁面使用了iframe。

譯者注:原書在這裡說的不是很清晰。Hosted Fields的意思是敏感欄位由我們頁面中的Braintree JavaScript客戶端通過Braintree伺服器生成並填入到頁面中,而不是在模板中直接編寫input欄位。簡單的說就是信用卡等敏感資訊的欄位是由Braintree託管生成,而不是我們自行編寫。

1.1註冊Braintree沙盒測試賬戶

需要註冊一個Braintree賬戶。才能使用整合支付功能。我們先註冊一個Braintree沙盒賬戶用於開發和測試。開啟

https://www.braintreepayments.com/sandbox,如下圖所示:

填寫表單建立使用者,之後會收到電子郵件驗證,驗證通過之後在https://sandbox.braintreegateway.com/login進行登入。可以得到自己的商戶ID和私有/公開金鑰如下圖所示:

這些資訊與使用Braintree API進行驗證交易有關,注意儲存好私鑰,不要洩露給他人。

1.2安裝Braintree的Python模組

Braintree為Python提供了一個模組操作其API,原始碼地址在https://github.com/braintree/braintree_python。我們將把這個braintree模組整合到站點中。

使用命令列安裝braintree模組:

pip install braintree==3.45.0

之後在settings.py裡配置:

# Braintree支付閘道器設定
BRAINTREE_MERCHANT_ID = 'XXX'  # 商戶ID
BRAINTREE_PUBLIC_KEY = 'XXX'  # 公鑰
BRAINTREE_PRIVATE_KEY = 'XXX'  # 私鑰

from braintree import Configuration, Environment

Configuration.configure(
    Environment.Sandbox,
    BRAINTREE_MERCHANT_ID,
    BRAINTREE_PUBLIC_KEY,
    BRAINTREE_PRIVATE_KEY
)

BRAINTREE_MERCHANT_IDBRAINTREE_PUBLIC_KEYBRAINTREE_PRIVATE_KEY的值替換成你自己的實際資訊。

注意此處的設定Environment.Sandbox,,表示我們當前整合的是沙盒環境。如果站點正式上線並且獲取了正式的Braintree賬戶,必須修改成Environment.Production。Braintree對於正式賬號會有新的商戶ID和公鑰/私鑰。

Braintree的基礎設定結束了,下一步是將支付閘道器和支付過程結合起來。

1.3整合支付閘道器

結賬過程是這樣的:

  1. 將商品加入到購物車
  2. 從購物車中選擇結賬
  3. 輸入信用卡資訊並且支付

針對支付功能,我們建立一個新的應用叫做payment

python manage.py startapp payment

編輯settings.py檔案,啟用該應用:

INSTALLED_APPS = [
    # ...
    'payment.apps.PaymentConfig',
]

payment現在已經被啟用。

客戶成功提交訂單後,必須將該頁面重定向到一個支付過程頁面(目前是重定向到一個簡單的成功頁面)。編輯orders應用中的views.py,增加如下匯入:

from django.urls import reverse
from django.shortcuts import render, redirect

在同一個檔案內,將order_create檢視的如下部分:

# 啟動非同步任務
order_created.delay(order.id)
return render(request, 'orders/order/created.html', locals())

替換成:

# 啟動非同步任務
order_created.delay(order.id)
# 在session中加入訂單id
request.session['order_id'] = order.id
# 重定向到支付頁面
return redirect(reverse('payment:process'))

這樣修改後,在成功建立訂單之後,session中就儲存了訂單ID的變數order_id,然後使用者被重定向至payment:processURL,這個URL稍後會編寫。

注意必須為order_created檢視啟動Celery。

每次我們向Braintree中傳送一個交易請求的時候,會生成一個唯一的交易ID號。因此我們在Order模型中增加一個欄位用於儲存這個交易ID號,這樣可以將訂單與Braintree交易聯絡起來。

編輯orders應用的models.py檔案,為Order模型新增一行:

class Order(models.Model):
    # ...
    braintree_id = models.CharField(max_length=150, blank=True)

之後執行資料遷移程式,每一個訂單都會儲存與其關聯的交易ID。目前準備工作都已經做完,剩下就是在支付過程中使用支付閘道器。

1.3.1使用Hosted Fields進行支付

Hosted Fields方式允許我們建立自定義的支付表單,使用自定義樣式和表現形式。Braintree JavaScript SDK會在頁面中動態的新增iframe框體用於展示Host Fields支付欄位。當用戶提交表單的時候,Hosted Fields會安全地提取使用者的信用卡等資訊,生成一個特徵字串(tokenize,令牌化)。如果令牌化過程成功,就可以使用這個特徵字串(token),通過檢視中的braintree模組發起一個支付申請。

為此需要建立一個支付檢視。這個檢視的工作流程如下:

  1. 使用者提交訂單時,檢視通過braintree模組生成一個token,這個token用於Braintree JavaScript 客戶端生成支付表單,並不是最終傳送給支付閘道器的token。為了方便以下把這個token稱為臨時token,把最終提交給Braintree網站的token叫做交易token。
  2. 檢視渲染支付表單所在的模板。頁面中的Braintree JavaScript 客戶端使用臨時token來生成頁面中的支付表單。
  3. 使用者輸入信用卡資訊並且提交支付表單後,Braintree JavaScript 客戶端會生成交易token,將這個交易token通過POST請求傳送到檢視
  4. 檢視獲取交易token之後,通過braintree模組向網站提交交易請求。

瞭解了工作流程之後,來編寫相關檢視,編輯payment應用中的views.py檔案,新增下列程式碼:

import braintree
from django.shortcuts import render, redirect, get_object_or_404
from orders.models import Order

def payment_process(request):
    order_id = request.session.get('order_id')
    order = get_object_or_404(Order, id=order_id)

    if request.method == "POST":
        # 獲得交易token
        nonce = request.POST.get('payment_method_nonce', None)
        # 使用交易token和附加資訊,建立並提交交易資訊
        result = braintree.Transaction.sale(
            {
                'amount': '{:2f}'.format(order.get_total_cost()),
                'payment_method_nonce': nonce,
                'options': {
                    'submit_for_settlement': True,
                }
            }
        )
        if result.is_success:
            # 標記訂單狀態為已支付
            order.paid = True
            # 儲存交易ID
            order.braintree_id = result.transaction.id
            order.save()
            return redirect('payment:done')
        else:
            return redirect('payment:canceled')

    else:
        # 生成臨時token交給頁面上的JS程式
        client_token = braintree.ClientToken.generate()
        return render(request,
                      'payment/process.html',
                      {'order': order,
                       'client_token': client_token})

這個payment_process檢視管理支付過程,工作流程如下 :

  1. 從session中取出由order_create檢視設定的order_id變數。
  2. 獲取Order物件,如果沒找到,返回404 Not Found錯誤
  3. 如果接收到POST請求,獲取交易tokenpayment_method_nonce,使用交易token和braintree.Transaction.sale()方法生成新的交易,該方法的幾個引數解釋如下:
    1. amount:總收款金額
    2. payment_method_nonce:交易token,由頁面中的Braintree JavaScript 客戶端生成。
    3. options:其他選項,submit_for_settlement設定為True表示生成交易資訊完畢的時候就立刻提交。
  4. 如果交易成功,通過設定paid屬性為True,將訂單標記為已支付,將交易ID儲存到braintree_id屬性中,之後重定向至payment:done,如果交易失敗就重定向至payment:canceled
  5. 如果檢視接收到GET請求,生成臨時token交給頁面中的Braintree JavaScript 客戶端。

下邊建立支付成功和失敗時的處理檢視,在payment應用的views.py中新增下列程式碼:

def payment_done(request):
    return render(request, 'payment/done.html')
def payment_canceled(request):
    return render(request, 'payment/canceled.html')

然後在payment目錄下建立urls.py,為上述檢視配置路由:

from django.urls import path
from . import views

app_name = 'payment'

urlpatterns = [
    path('process/', views.payment_process, name='process'),
    path('done/', views.payment_done, name='done'),
    path('canceled/', views.payment_canceled, name='canceled'),
]

這是支付流程的路由,配置瞭如下URL模式:

  • process:處理支付的檢視
  • done:支付成功的檢視
  • canceled:支付未成功的檢視

編輯myshop專案的根urls.py檔案,為payment應用配置二級路由:

urlpatterns = [
    # ...
    path('payment/', include('payment.urls', namespace='payment')),
    path('', include('shop.urls', namespace='shop')),
]

依然要注意這一行要放到shop.urls上邊,否則無法被解析到。

之後是建立檢視,在payment目錄下建立templates/payment/目錄,並在其中建立 process.html, done.html,canceled.html三個模板。先來編寫process.html:

payment應用內建立下列目錄和檔案結構:

templates/
    payment/
        process.html
        done.html
        canceled.html

編輯payment/process.html,新增下列程式碼:

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

{% block title %}Pay by credit card{% endblock %}

{% block content %}
  <h1>Pay by credit card</h1>
  <form action="." id="payment" method="post">

    <label for="card-number">Card Number</label>
    <div id="card-number" class="field"></div>

    <label for="cvv">CVV</label>
    <div id="cvv" class="field"></div>

    <label for="expiration-date">Expiration Date</label>
    <div id="expiration-date" class="field"></div>

    <input type="hidden" id="nonce" name="payment_method_nonce" value="">
    {% csrf_token %}
    <input type="submit" value="Pay">
  </form>
    <!-- Load the required client component. -->
  <script src="https://js.braintreegateway.com/web/3.29.0/js/client.min.js"></script>
    <!-- Load Hosted Fields component. -->
  <script src="https://js.braintreegateway.com/web/3.29.0/js/hosted-fields.min.js"></script>
  <script>
    var form = document.querySelector('#payment');
    var submit = document.querySelector('input[type="submit"]');

    braintree.client.create({
        authorization: '{{ client_token }}'
    }, function (clientErr, clientInstance) {
        if (clientErr) {
            console.error(clientErr);
            return;
        }

        braintree.hostedFields.create({
            client: clientInstance,
            styles: {
                'input': {'font-size': '13px'},
                'input.invalid': {'color': 'red'},
                'input.valid': {'color': 'green'}
            },
            fields: {
                number: {selector: '#card-number'},
                cvv: {selector: '#cvv'},
                expirationDate: {selector: '#expiration-date'}
            }
        }, function (hostedFieldsErr, hostedFieldsInstance) {
            if (hostedFieldsErr) {
                console.error(hostedFieldsErr);
                return;
            }

            submit.removeAttribute('disabled');

            form.addEventListener('submit', function (event) {
                event.preventDefault();

                hostedFieldsInstance.tokenize(function (tokenizeErr, payload) {
                    if (tokenizeErr) {
                        console.error(tokenizeErr);
                        return;
                    }
                    // set nonce to send to the server
                    document.getElementById('nonce').value = payload.nonce;
                    // submit form
                    document.getElementById('payment').submit();
                });
            }, false);
        });
    });
  </script>
{% endblock %}

這是使用者填寫信用卡資訊並且提交支付的模板,我們用<div>替代<input>使用在信用卡號,CVV碼和過期日期欄位上。這些欄位就是Braintree JavaScript客戶端渲染的iframe欄位。還使用了一個名稱為payment_method_nonce<input>元素用於提交交易ID到後端。

在模板中還匯入了Braintree JavaScript SDK的client.min.js和Hosted Fields元件hosted-fields.min.js,然後執行了下列JS程式碼:

  1. 使用braintree.client.create()方法,傳入client_tokenpayment_process視圖裡生成的臨時token,例項化Braintree JavaScript 客戶端。
  2. 使用braintree.hostedFields.create()例項化Hosted Field元件
  3. input欄位應用自定義樣式
  4. cardnumbercvv, 和expiration-date欄位設定id選擇器
  5. 給表單的submit行為繫結一個事件,當表單被點選提交時,Braintree SDK 使用表單中的資訊,生成交易token放入payment_method_nonce欄位中,然後提交表單。

編輯payment/done.html檔案,新增下列程式碼:

{% extends "shop/base.html" %}
{% block content %}
    <h1>Your payment was successful</h1>
    <p>Your payment has been processed successfully.</p>
{% endblock %}

這是訂單成功支付時使用者被重定向的頁面。

編輯canceled.html,新增下列程式碼:

{% extends "shop/base.html" %}
{% block content %}
    <h1>Your payment has not been processed</h1>
    <p>There was a problem processing your payment.</p>
{% endblock %}

這是訂單未支付成功時使用者被重定向的頁面。之後我們來試驗一下付款。

1.4測試支付

開啟系統命令列視窗然後執行RabbitMQ:

rabbitmq-server

再啟動一個命令列視窗,啟動Celery worker:

celery -A myshop worker -l info

再啟動一個命令列視窗,啟動站點:

python manage.py runserver

之後在瀏覽器中開啟http://127.0.0.1:8000/加入一些商品到購物車,提交訂單,當按下PLACE ORDER按鈕後,訂單資訊被儲存進資料庫,訂單ID被附加到session上,然後進入支付頁面。

支付頁面從session中取得訂單id然後在iframe中渲染Hosted Fields,像下圖所示:

可以看一下頁面的HTML程式碼,從而理解什麼是Hosted Fields。

針對沙盒測試環境,Braintree提供了一些測試用的信用卡資料,可以進行付款成功或失敗的測試,可以在https://developers.braintreepayments.com/guides/credit-cards/testing-go-live/python找到,我們來使用4111 1111 1111 1111這個信用卡號,在CVV碼中填入123,到期日期填入未來的某一天比如12/20

之後點選Pay,應該可以看到成功頁面:

說明付款已經成功。可以在https://sandbox.braintreegateway.com/login登入,然後在左側選單選Transaction裡搜尋最近的交易,可以看到如下資訊:

譯者注:Braintree網站在成書後有部分改版,讀者看到的支付詳情頁面可能與上述圖片有一些區別。

然後再檢視管理站點http://127.0.0.1:8000/admin/orders/order/中的對應記錄,該訂單應該已經被標記為已支付,而且記錄了交易ID,如下圖所示:

我們現在就成功集成了支付功能。

1.5正式上線

在沙盒環境中測試通過之後,需要正式上線的話,需要到https://www.braintreepayments.com建立正式賬戶。

在部署到生產環境時,需要將settings.py中的商戶ID和公鑰私鑰更新為正式賬戶的對應資訊,然後將其中的Environment.Sandbox修改為Environment.Production。正式上線的具體步驟可以參考:https://developers.braintreepayments.com/start/go-live/python

2匯出訂單為CSV檔案

有時我們想將某個模型中的資料匯出為一個檔案,用於在其他系統匯入。常用的一種資料交換格式是CSV(逗號分隔資料)檔案。CSV檔案是一個純文字檔案,包含很多條記錄。通常一行是一條記錄,用特定的分隔符(一般是逗號)分隔每個欄位的值。我們準備自定義管理後臺,增加匯出CSV檔案的功能。

2.1給管理後臺增加自定義管理行為(actions)

Django允許對管理後臺的很多內容進行自定義修改。我們準備在列出具體資料的檢視內增加匯出CSV檔案的功能。

一個管理行為是指如下操作:使用者從管理後臺列出某個模型中具體記錄的頁面內,使用複選框選中要操作的記錄,然後從下拉選單中選擇一項操作,之後就會針對所有選中的記錄執行操作。這個action的位置如下圖所示:

建立自定義管理行為可以讓管理員批量對記錄進行操作。

可以通過寫一個符合要求的自定義函式作為一項管理行為,這個函式要接受如下引數:

  • 當前顯示的ModelAdmin
  • 當前的request物件,是一個HttpResponse例項
  • 使用者選中的內容組成的QuerySet

在選中一個action選項然後點選旁邊的Go按鈕的時候,該函式就會被執行。

我們就準備在下拉action清單裡增加一項匯出CSV資料的功能,為此先來修改orders應用中的admin.py檔案,將下列程式碼加在OrderAdmin類定義之前:

import csv
import datetime
from django.http import HttpResponse

def export_to_csv(modeladmin, request, queryset):
    opts = modeladmin.model._meta
    response = HttpResponse(content_type='text/csv')
    response['Content-Disposition'] = 'attachment; filename={}.csv'.format(opts.verbose_name)
    writer = csv.writer(response)

    fields = [field for field in opts.get_fields() if not field.many_to_many and not field.one_to_many]
    writer.writerow(field.verbose_name for field in fields)

    for obj in queryset:
        data_row = []
        for field in fields:
            value = getattr(obj, field.name)
            if isinstance(value, datetime.datetime):
                value = value.strftime('%d/%m/%Y')
            data_row.append(value)
        writer.writerow(data_row)
    return response

export_to_csv.short_description = 'Export to CSV'

在這個函式裡我們做了如下事情:

  1. 建立一個HttpResponse物件,將其內容型別設定為text/csv,以告訴瀏覽器將其視為一個CSV檔案。還為請求頭附加了Content-Disposition頭部資訊,告訴瀏覽器這個請求帶有一個附加檔案。
  2. 建立一個CSV的writer物件,用於向Http響應物件response中寫入CSV檔案資料。
  3. 通過_metaget_fields()方法獲取所有欄位名,動態獲取model的欄位,排除了所有一對多和多對多欄位。
  4. 將欄位名寫入到響應的CSV資料中,作為第一行資料,即表頭
  5. 迭代QuerySet,將其中每一個物件的資料寫入一行中,注意特別對datetime採用了格式化功能,以轉換成字串。
  6. 最後設定了該函式物件的short_description屬性,該屬性的值為在action列表中顯示的功能名稱。

這樣我們就建立了一個通用的管理功能,可以操作任何ModelAdmin物件。

之後在OrderAdmin類中增加這個新的export_to_csv功能:

class OrderAdmin(admin.ModelAdmin):
WeasyPrint    # ...
    actions = [export_to_csv]

在瀏覽器中開啟http://127.0.0.1:8000/admin/orders/order/檢視訂單類,頁面如下:

選擇一些訂單,然後選擇上邊的Export to CSV功能,然後點選Go按鈕,瀏覽器就會下載一個order.csv檔案。

譯者注:此處下載的檔名可能不是order.csv,這是因為原書沒有在orders應用的models.py中為Order類的meta類增加verbose_name屬性,手工增加verboser_name的值為order,這樣才能下載到和原書裡寫的名稱一樣的order.csv檔案。

使用文字編輯器開啟剛下載的CSV檔案,可以看到裡邊的內容類似:

ID,first name,last name,email,address,postal
code,city,created,updated,paid,braintree id
3,Antonio,Melé,[email protected],Bank Street,WS
J11,London,25/02/2018,25/02/2018,True,2bwkx5b6

可以看到,實現管理功能的方法很直接。Django中將資料輸出為CSV的說明可以參考https://docs.djangoproject.com/en/2.0/howto/outputting-csv/

3用自定義檢視擴充套件管理後臺的功能

不僅僅是配置ModelAdmin,建立管理行為和覆蓋內建模板,有時候可能需要對管理後臺進行更多的自定義。這時你需要建立自定義的管理檢視。使用管理檢視,就可以實現自己想要的功能,要注意的只是自定義管理檢視應該只允許管理員進行操作,同時繼承內建模板以保持風格一致性。

我們這次來修改一下管理後臺,增加一個自定義的功能用於顯示一個訂單的資訊。修改orders應用中的views.py檔案,增加如下內容:

from django.contrib.admin.views.decorators import staff_member_required
from django.shortcuts import get_object_or_404
from .models import Order

@staff_member_required
def admin_order_detail(request, order_id):
    order = get_object_or_404(Order, id=order_id)
    return render(request, 'admin/orders/order/detail.html', {'order': order})

@staff_member_required裝飾器只允許is_staffis_active欄位同時為True的使用者才能使用被裝飾的檢視。在這個檢視中,通過傳入的id取得對應的Order物件。

然後配置orders應用的urls.py檔案,增加一條路由:

path('admin/order/<int:order_id>/', views.admin_order_detail, name='admin_order_detail')

然後在order應用的templates/目錄下建立如下檔案目錄結構:

admin/
    orders/
        order/
            detail.html

編輯這個detail.html,新增下列程式碼:

{% extends "admin/base_site.html" %}
{% load static %}
{% block extrastyle %}
    <link rel="stylesheet" type="text/css" href="{% static "css/admin.css" %}"/>
{% endblock %}
{% block title %}
    Order {{ order.id }} {{ block.super }}
{% endblock %}
{% block breadcrumbs %}
    <div class="breadcrumbs">
        <a href="{% url "admin:index" %}">Home</a> ›
        <a href="{% url "admin:orders_order_changelist" %}">Orders</a>
        ›
        <a href="{% url "admin:orders_order_change" order.id %}">Order {{ order.id }}</a>
        › Detail
    </div>
{% endblock %}
{% block content %}
    <h1>Order {{ order.id }}</h1>
    <ul class="object-tools">
        <li>
            <a href="#" onclick="window.print();">Print order</a>
        </li>
    </ul>
    <table>
        <tr>
            <th>Created</th>
            <td>{{ order.created }}</td>
        </tr>
        <tr>
            <th>Customer</th>
            <td>{{ order.first_name }} {{ order.last_name }}</td>
        </tr>
        <tr>
            <th>E-mail</th>
            <td><a href="mailto:{{ order.email }}">{{ order.email }}</a></td>
        </tr>
        <tr>
            <th>Address</th>
            <td>{{ order.address }}, {{ order.postal_code }} {{ order.city }}</td>
        </tr>
        <tr>
            <th>Total amount</th>
            <td>${{ order.get_total_cost }}</td>
        </tr>
        <tr>
            <th>Status</th>
            <td>{% if order.paid %}Paid{% else %}Pending payment{% endif %}</td>
        </tr>
    </table>
    <div class="module">
        <div class="tabular inline-related last-related">
            <table>
                <caption>Items bought</caption>
                <thead>
                <tr>
                    <th>Product</th>
                    <th>Price</th>
                    <th>Quantity</th>
                    <th>Total</th>
                </tr>
                </thead>
                <tbody>
                {% for item in order.items.all %}
                    <tr class="row{% cycle "1" "2" %}">
                        <td>{{ item.product.name }}</td>
                        <td class="num">${{ item.price }}</td>
                        <td class="num">{{ item.quantity }}</td>
                        <td class="num">${{ item.get_cost }}</td>
                </tr>
                {% endfor %}
                <tr class="total">
                    <td colspan="3">Total</td>
                    <td class="num">${{ order.get_total_cost }}</td>
                </tr>
                </tbody>
            </table>
        </div>
    </div>
{% endblock %}

這個模板用於在管理後臺顯示訂單詳情。模板繼承了admin/base_site.html母版,這個母版包含Django管理站點的基礎結構和CSS類,然後還載入了自定義的樣式表css/admin.css

自定義CSS樣式表在隨書程式碼中,像之前的專案一樣將其複製到對應目錄。

我們使用的塊名稱都定義在母版中,在其中編寫了展示訂單詳情的部分。

當你需要繼承Django的內建模板時,必須瞭解內建模板的結構,在https://github.com/django/django/tree/2.1/django/contrib/admin/templates/admin可以找到內建模板的資訊。

如果需要覆蓋內建模板,需要將自己編寫的模板命名為與原來模板相同,然後複製到templates下,設定與內建模板相同的相對路徑和名稱。管理後臺就會優先使用當前專案下的模板。

最後,還需要再管理後臺中為每個Order物件增加一個連結到我們自行編寫的檢視,編輯orders應用的admin.py檔案,在OrderAdmin類之前增加如下程式碼:

from django.urls import reverse
from django.utils.safestring import mark_safe

def order_detail(obj):
    return mark_safe('<a href="{}">View</a>'.format(reverse('orders:admin_order_detail', args=[obj.id])))

這個函式接受一個Order物件作為引數,返回一個解析後的admin_order_detail名稱對應的URL,由於Django預設會將HTML程式碼轉義,所以加上mark_safe

使用mark_safe可以不讓HTML程式碼轉義。使用mark_safe的時候,確保對於使用者的輸入依然要進行轉義,以防止跨站指令碼攻擊。

然後編輯OrderAdmin類來顯示連結:

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

然後啟動站點,訪問http://127.0.0.1:8000/admin/orders/order/,可以看到新增了一列:

點選任意一個訂單的View連結檢視詳情,會進入Django管理後臺風格的訂單詳情頁:

4動態生成PDF發票

我們現在已經實現了完整的結賬和支付功能,可以為每個訂單生成一個PDF發票。有很多Python庫都可以用來生成PDF,常用的是Reportlab庫,該庫也是django 2.0官方推薦使用的庫,可以在https://docs.djangoproject.com/en/2.0/howto/outputting-pdf/檢視詳情。

大部分情況下,PDF檔案中都要包含一些樣式和格式,這個時候通過渲染後的HTML模板生成PDF更加方便。我們在Django中整合一個模組來按照上述方法轉換PDF。這裡我們使用WeasyPrint庫,這個庫用來從HTML模板生成PDF檔案。

4.1安裝WeasyPrint

需要先按照http://weasyprint.org/docs/install/#platforms的指引安裝相關依賴,之後通過pip安裝WeasyPrint:

pip install WeasyPrint==0.42.3

譯者注:WeasyPrint在Windows下的配置非常麻煩,還未必能夠成功,推薦Windows使用者使用Linux虛擬機器。

4.2建立PDF模板

需要建立一個模板作為輸入給WeasyPrint的資料。我們建立一個帶有訂單內容和CSS樣式的模板,通過Django渲染,將最終生成的頁面傳給WeasyPrint。

orders應用的templates/orders/order/目錄下建立pdf.html檔案,新增下列程式碼:

<html>
<body>
<h1>My Shop</h1>
<p>
    Invoice no. {{ order.id }}<br>
    <span class="secondary">
{{ order.created|date:"M d, Y" }}
</span>
</p>
<h3>Bill to</h3>
<p>
    {{ order.first_name }} {{ order.last_name }}<br>
    {{ order.email }}<br>
    {{ order.address }}<br>
    {{ order.postal_code }}, {{ order.city }}
</p>
<h3>Items bought</h3>
<table>
    <thead>
    <tr>
        <th>Product</th>
        <th>Price</th>
        <th>Quantity</th>
        <th>Cost</th>
    </tr>
    </thead>
    <tbody>
    {% for item in order.items.all %}
        <tr class="row{% cycle "1" "2" %}">
            <td>{{ item.product.name }}</td>
            <td class="num">${{ item.price }}</td>
            <td class="num">{{ item.quantity }}</td>
            <td class="num">${{ item.get_cost }}</td>
    </tr>
    {% endfor %}
    <tr class="total">
        <td colspan="3">Total</td>
        <td class="num">${{ order.get_total_cost }}</td>
    </tr>
    </tbody>
</table>
<span class="{% if order.paid %}paid{% else %}pending{% endif %}">
{% if order.paid %}Paid{% else %}Pending payment{% endif %}
</span>
</body>
</html>

譯者注:注意第五行標紅的部分,原書錯誤的寫成了</br>

這個模板的內容很簡單,使用一個<table>元素展示訂單的使用者資訊和商品資訊,還添加了訊息顯示訂單是否已支付。

4.3建立渲染PDF的檢視

我們來建立在管理後臺內生成訂單PDF檔案的檢視,在orders應用的views.py檔案內增加下列程式碼:

from django.conf import settings
from django.http import HttpResponse
from django.template.loader import render_to_string
import weasyprint

@staff_member_required
def admin_order_pdf(request, order_id):
    order = get_object_or_404(Order, id=order_id)
    html = render_to_string('orders/order/pdf.html', {'order': order})
    response = HttpResponse(content_type='application/pdf')
    response['Content-Disposition'] = 'filename="order_{}"'.format(order.id)
    weasyprint.HTML(string=html).write_pdf(response, stylesheets=[weasyprint.CSS(settings.STATIC_ROOT + 'css/pdf.css')])
    return response

這是生成PDF檔案的檢視,使用了@staff_member_required裝飾器使該檢視只能由管理員訪問。通過ID獲取Order物件,然後使用render_to_string()方法渲染orders/order/pdf.html,渲染後的模板以字串形式儲存在html變數中。然後建立一個新的HttpResponse物件,併為其附加application/pdfContent-Disposition請求頭資訊。使用WeasyPrint從字串形式的HTML中轉換PDF檔案並寫入HttpResponse物件。這個生成的PDF會帶有STATIC_ROOT路徑下的css/pdf.css中的樣式,最後返回響應。

如果發現檔案缺少CSS樣式,記得把CSS檔案從隨書目錄中放入shop應用的static/目錄下。

我們這裡還沒有配置STATIC_ROOT變數,這個變數規定了專案的靜態檔案存放的路徑。編輯myshop專案的settings.py檔案,新增下面這行:

STATIC_ROOT = os.path.join(BASE_DIR, 'static/')

然後執行如下命令:

python manage.py collectstatic

之後會看到:

120 static files copied to 'code/myshop/static'.

這個命令會把所有已經啟用的應用下的static/目錄中的檔案,複製到STATIC_ROOT指定的目錄中。每個應用都可以有自己的static/目錄存放靜態檔案,還可以在STATICFILES_DIRS中指定其他的靜態檔案路徑,當執行collectstatic時,會把所有STATICFILES_DIRS目錄內的檔案都複製過來。如果再次執行collectstatic,會提示是否需要覆蓋已經存在的靜態檔案。

譯者注:雖然將靜態檔案分開存放在每個應用的static/下可以正常執行開發中的站點,但在正式上線的最好統一靜態檔案的存放地址,以方便配置Web服務程式。

編輯orders應用的urls.py檔案,為檢視配置URL:

urlpatterns = [
    # ...
    path('admin/order/<int:order_id>/pdf/', views.admin_order_pdf, name='admin_order_pdf'),
]

像匯出CSV一樣,我們要在管理後臺的展示頁面中增加一個連結到這個檢視的URL。開啟orders應用的admin.py檔案,在OrderAdmin類之前增加:

def order_pdf(obj):
    return mark_safe('<a href="{}">PDF</a>'.format(reverse('orders:admin_order_pdf', args=[obj.id])))
order_pdf.short_description = 'Invoice'

如果指定了short_description屬性,Django就會用該屬性的值作為列名。

OrderAdminlist_display增加這個新的欄位order_pdf

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

在瀏覽器中開啟http://127.0.0.1:8000/admin/orders/order/,可以看到新增了一列欄位用於轉換PDF:

點選PDF連結,瀏覽器應該會下載一個PDF檔案,如果是尚未支付的訂單,樣式如下:

已經支付的訂單,則類似這樣:

4.4使用電子郵件傳送PDF檔案

當支付成功的時,我們傳送帶有PDF發票的郵件給使用者。編輯payment應用中的views.py檢視,新增如下匯入語句:

from django.template.loader import render_to_string
from django.core.mail import EmailMessage
from django.conf import settings
import weasyprint
from io import BytesIO

payment_process檢視中,order.save()這行之後,以相同的縮排新增下列程式碼:

def payment_process(request):
    # ......
    if request.method == "POST":
        # ......
        if result.is_success:
            # ......
            order.save()

            # 建立帶有PDF發票的郵件
            subject = 'My Shop - Invoice no. {}'.format(order.id)
            message = 'Please, find attached the invoice for your recent purchase.'
            email = EmailMessage(subject, message, '[email protected]', [order.email])

            # 生成PDF檔案
            html = render_to_string('orders/order/pdf.html', {'order': order})
            out = BytesIO()
            stylesheets = [weasyprint.CSS(settings.STATIC_ROOT + 'css/pdf.css')]
            weasyprint.HTML(string=html).write_pdf(out, stylesheets=stylesheets)

            # 附加PDF檔案作為郵件附件
            email.attach('order_{}.pdf'.format(order.id), out.getvalue(), 'application/pdf')

            # 傳送郵件
            email.send()

            return redirect('payment:done')
        else:
            return redirect('payment:canceled')
    else:
        # ......

這裡使用了EmailMessage類建立一個郵件物件email,然後將模板渲染到html變數中,然後通過WeasyPrint將其寫入一個BytesIO二進位制位元組物件,之後使用attach方法,將這個位元組物件的內容設定為EmailMessage的附件,同時設定檔案型別為PDF,最後傳送郵件。

記得在settings.py中設定SMTP伺服器,可以參考第二章。

現在,可以嘗試完成一個新的付款,並且在郵箱內接收PDF發票。

總結

在這一章中,我們集成了支付閘道器,自定義了Django管理後臺,還學習瞭如何將資料以CSV檔案格式匯出和動態生成PDF檔案。

下一章我們將深入瞭解Django專案的國際化和本地化設定,還將建立一個優惠碼功能和商品推薦引擎。