管理支付与订单

上一章制作了一个带有商品展示和购物车功能的电商网站雏形,同时也学到了如何使用Celery给项目增加异步任务。

这一章就来给网站增加实际的支付功能,以及通过订单数据生成PDF发票的功能,主要有三大块内容:

  • 集成支付网关到项目中
  • 将订单数据导出成CSV文件
  • 动态生成PDF文件

其中还将学习如何为管理后台建立自定义功能和视图

集成支付网关

支付网关是一种进行支付的第三方代理程序(网站)。由于涉及到支付,不可能每个人都去和支付清算机构研发属于自己的支付程序。就像日常生活中我们付款给另外一个人,都需要通过银行或者支付机构这些第三方进行操作一样,对于我们网站的付款,通过支付网关将支付处理过程交给一个可信任的第三方来完成,只需要得到是否成功支付的结果,无需在自己的网站上处理信用卡和其他支付方式的信息并接入到清算机构进行实际支付。

支付网关有很多可供选择,像在国内就可以选择支付宝,微信支付等。在本书的上一版中,集成的是Paypal。这次要集成的是叫做”Braintree”的支付网关。

Braintree使用较为广泛,是Uber和Airbnb的支付方式。Braintree提供了一套API用于支持信用卡,PayPal,Android Pay和Apple Pay等支付方式,官方网站在https://www.braintreepayments.com/

Braintree提供了很多集成的选择,最简单的集成方式就是Drop-in集成,包含一个预先建立好的支付表单。但是为了自定义一些支付过程中的内容,这里选择使用高级的Hosted Field方式进行集成。这里可以看到详细的帮助文档。

支付表单与普通网站交互的表单不同,其中包含的信用卡号,CVV码,过期日期等信息必须要得到安全处理,Hosted Field集成方式将这些字段展示给用户的时候,在页面中渲染的是一个iframe框架。我们可以来自定义该字段的外观,但必须要遵循Payment Card Industry (PCI)安全支付的要求。并且由于可以修改外观,用户并不会注意到页面内有一个iframe框架。

建立Braintree沙盒账户

就像之前使用social-auth需要注册开发者账户一样,使用Braintree的支付网关,也必须先注册一个Braintree沙盒账户用户开发和测试。打开https://www.braintreepayments.com/sandbox,如下图所示:

填写表单创建用户,之后会收到电子邮件验证,验证通过之后在https://sandbox.braintreegateway.com/login进行登录。可以得到自己的商户ID和私有/公开密钥如下图所示:

这些内容和一会使用API过程中的验证有关,注意保存好这些资料,不要泄露给他人。

Python中安装Braintree模块

Braintree为Python提供了一个模块,源代码地址在https://github.com/braintree/braintree_python。使用命令行安装:

pip install braintree==3.45.0

译者安装了目前最新版3.48版本。之后在settings.py里需要配置Braintree的相关设置:

# Braintree支付网关设置
BRAINTREE_MERCHANT_ID = 'g38f76cnqg87znvp'  # Merchant ID
BRAINTREE_PUBLIC_KEY = 'hnhdn4dj9ghkf9dd'  # Public Key
BRAINTREE_PRIVATE_KEY = '7e61ac77680f6b66d7997c39ef06f5bc'  # Private key
from braintree import Configuration, Environment

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

将刚才注册后得到的信息填入到其中。注意此处的设置 Environment.Sandbox,,我们目前是在沙盒环境中操作,不是正式支付环境。如果是在正式支付环境中,必须修改成 Environment.Production。由于我们目前是沙盒测试账号,这里的Merchant ID 不是可以用于生产环境的正式ID。Braintree对于正式账号会有新的商户ID和公钥/私钥。

Braintree的工作准备好了,下一步是将网关和我们的网站结合起来。

集成支付网关的准备工作

使用过电商网站的用户对结账过程应该比较熟悉:

  1. 将商品加入到购物车
  2. 从购物车中选择结账
  3. 在弹出的页面中选择支付方式/输入支付信息

针对支付功能,我们建立一个新的应用叫做payment,建立之后在settings.py内注册好。在客户从购物车选择结账的时候,目前的返回页面是一个成功页面,必须将该页面重定向到一个新的支付页面。

编辑orders应用中的views.py,修改order_create视图的启动异步发送邮件那行之后的内容:

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

# 启动异步发送邮件
order_created.delay(order.id)
# 在session中加入订单id
request.session['order_id'] = order.id
# 重定向到支付页面
return redirect(reverse('payment:process'))

在订单获取成功后,session中就保存了一个订单id,然后页面会跳转到支付页面。通过session拿到订单id来进行支付,如果成功,就修改订单的状态为已支付。payment:process的URL很快我们就会配置。

需要了解的是:由于我们的支付请求是提交到Braintree去的,每次在Braintree中创建一个订单(支付请求)的时候,会生成一个唯一的交易ID号。因为必须在Order模型中增加一个字段用于存储我们的本地订单与提交到Braintree进行支付的交易ID号的对应关系。

编辑orders应用的models.py,为Order模型新增一行:

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

之后执行makemigrations和migrate,修改数据库。目前准备工作都已经做完,剩下的就是在payment应用中使用Braintree。

使用Hosted Fields方式集成支付网关

Hosted Fields方式允许我们创建自定义的支付表单,使用各种样式和表现形式。Braintree JavaScript SDK会在页面中动态的添加一个iframe框体用于展示支付字段。

当用户提交表单的时候,Hosted Fields会安全的将用户的信息特征提取,生成一个特征字符串(token)。如果令牌化过程成功,就可以使用这个特征字符串(token),向Braintree发起一个支付申请。

为此需要建立一个视图用于和Braintree通信。这个视图的工作流程如下:

  1. 用户提交订单时,通过braintree模块生成一个token,这个token用于实例化下一步的Braintree JS 客户端,并不是最终发送给支付网关的上边说的token。为了方便以下把这个token称为临时token,把最终提交给Braintree网站的token叫做交易token。
  2. 视图渲染支付表单。Braintree JS 客户端使用第一步里生成的临时token和我们自定义的表单数据来生成页面中的支付表单。
  3. 用户提交支付表单后,Braintree JS 客户端会生成交易token
  4. 视图拿到交易token之后,通过braintree模块向网站提交支付请求。

了解了工作流程之后,来编写相关视图,编辑payment应用中的views.py:

from django.shortcuts import render, redirect, get_object_or_404
import braintree
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)
    # POST请求则处理支付
    if request.method == "POST":
        # 获得最终生成的交易token
        nonce = request.POST.get('payment_method_nonce', None)
        # 使用token和附加信息,建立并向Braintree提交交易信息
        result = braintree.Transaction.sale(
            {
                'amount': '{:2f}'.format(order.get_total_cost()),
                'payment_method_nonce': nonce,
                'options': {
                    'submit_for_settlement': True,
                }
            }
        )
        # 如果提交交易信息成功,将已支付和交易id记录到Order表中
        if result.is_success:
            order.paid = True
            order.braintree_id = result.transaction.id
            order.save()
            return redirect('payment:done')
        else:
            return redirect('payment:canceled')

    # GET请求则生成临时token交给前端页面以生成支付表单供用户填写
    else:
        client_token = braintree.ClientToken.generate()
        return render(request,
                      'payment/process.html',
                      {'order': order,
                       'client_token': client_token})

这个视图的逻辑解释如下:

  1. 在order_create中我们向session中传入了订单id并且立刻重定向到这个视图,从中取出订单id,然后获取订单对象
  2. 然后是分支,如果是POST请求,说明用户提交了支付申请,从页面里拿到Braintree JS 客户端生成的交易token,附加上其他信息然后发送给braintree支付网站。这里边的几个参数解释如下:
    1. amount:总收款金额
    2. payment_method_nonce:Braintree JS 客户端生成的交易token
    3. options:其他选项,submit_for_settlement设置为True表示生成交易信息完毕的时候就立刻提交。
  3. 之后根据result的结果来判断,如果成功就将该订单标记为已支付,记录交易id。不成功就跳转到cancel页面。
  4. 如果是GET请求,说明用户新进入到支付页面,生成临时token交给前端的Braintree JS 代理。

下边建立支付成功和失败时候的处理视图,继续编辑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'),
]

再去配置项目的根路由:

path('pyament/',include('payment.urls',namespace='payment')),

依然要注意这一行要放到shop.urls上边,否则无法被解析到。

之后是建立视图,在payment目录下建立templates/payment/目录,并在其中建立 process.html, done.html,canceled.html三个模板。先来编写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 %}

在页面的表单部分,让用户填写信用卡号,CVV码和过期日期。然后埋了一个隐藏的元素用于存放交易token。

之后的JS代码部分,导入了关键的Braintree网站上的JS客户端代码和Hosted Field组件。JS代码解释如下:

  1. 使用braintree.client.create,用authorization: ‘{{ client_token }}’也就是视图里生成的临时token,来进行后边的控制表单和生成交易token等一系列动作。
  2. braintree.hostedFields.create()实例化hostfield对象
  3. 给页面中的元素加上了ID,根据ID来选择元素,对表单应用一些CSS样式
  4. 给提交按钮绑定了事件,点击的话就生成交易token,然后提交表单。

这里的很多写法来自于Braintree提供的文档,有兴趣的读者可以在此查看。

再来编辑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 %}

之后可以启动站点了,记得还需要启动Celery和RabbitMQ,否则无法发送邮件。加入一些商品到购物车,点击Checkout之后Place order,可以看到出现了支付页面:

针对沙盒测试环境,Braintree提供了一些测试用的信用卡资料,可以在https://developers.braintreepayments.com/guides/credit-cards/testing-go-live/python找到,实际输入一些来看看是否成功或者失败。

在卡号中输入4111 1111 1111 1111,CVV填入123,到期日期填入未来的某一天比如12/20(2020年12月):

之后点击Pay,应该可以看到成功页面:

说明付款已经成功。可以在https://sandbox.braintreegateway.com/login登录,然后在左侧菜单选Transaction里搜索最近的交易,可以看到如下信息:

然后再查看管理站点http://127.0.0.1:8000/admin/orders/order/中的对应记录,看一下Paid字段和Braintree_id字段,这里看到该交易id和上一张图片交易清单里的id不同,是因为Braintree页面已经改版,译者截取了自己的交易信息,实际上查看Order订单库的braintree_id字段,其内容是和交易清单里的信息完全对应的,这里只是示例:

正式上线

在沙盒环境中测试通过之后,需要正式上线的话,需要重新到https://www.braintreepayments.com注册账号,并且填写组织机构名称,等待认证通过之后,就拥有了正式的Braintree账户。

此时会获得新的商户ID以及公钥/私钥,需要将其更新到settings.py中去,并将其中的 Environment.Sandbox修改为 Environment.Production。正式上线的具体步骤可以参考:https://developers.braintreepayments.com/start/go-live/python

导出订单为CSV文件

很多情况下,我们的网站将数据库中的信息导出,可能为了和其他系统进行数据交换,或者给人阅读。常用的数据交换格式除了之前我们提到过的JSON,CSV(逗号分隔数据)文件也是一种得到广泛使用的数据格式。

CSV文件是一个纯文本文件,包含很多条记录。通常一行是一条记录,用逗号分隔其中的每个字段对应的值。由于导出网站数据库数据这种功能一般不会对普通用户开放,所以这里我们准备自定义管理后台,增加导出CSV文件的功能。

给管理后台增加自定义功能(actions)

这里的actions,是特指Django提供的下拉式功能菜单中的具体选项,如图所示,Django默认对于数据提供了删除的功能:

Django提供针对管理后台的很多设置。我们准备通过修改actions列表视图,来为管理后台增加自定义功能。

一个功能的工作流程是:一个用户先从列出的所有字段或者对象中选择要处理的内容,然后从功能菜单中选择要使用的功能,之后执行该功能。

可以通过写一个符合要求的自定义函数作为一项action,这个函数要接受如下参数:

  • 当前的ModelAdmin类,也就是在操作哪个数据表
  • 当前的request对象
  • 用户选中的内容,也就是一个QuerySet

在选中一个action选项然后点击旁边的Go按钮的时候,该函数就会被执行。

我们就准备在下拉action清单里增加一项导出CSV数据的功能,为此先来修改orders应用中的admin.py,将下列代码加在定义 OrderAdmin 类之前:

import csv
import datetime
from django.http import HttpResponse

def exprot_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


exprot_to_csv.short_description = 'Export to CSV'

在这个函数里我们做了如下事情:

  1. 建立一个HttpResponse对象,类型是text/csv。
  2. 还附加了一个Header信息,告诉这个请求带有一个附加文件。从当前的类,也就是modeladmin参数里,拿其元类,然后下边用到元类的verboser_属性的值用于生成文件名。
  3. 以HttpResponse作为参数实例化一个writer对象,用于后边向HTTP请求里写数据。
  4. 取当前类的所有不是外键的字段对象,放到列表中去。
  5. 使用一个两层嵌套for循环,对于传入的QuerySet参数,对其中的每一个数据对象(一行数据),采用自省的方式,拿到该对象的所有字段名的值。然后挨个加入到一个临时列表中,之后将列表作为一行数据写入到HttpResponse中。
  6. 最后返回HTTP响应
  7. 最后设置了该函数对象的.short_description属性,该属性的值为在页面中显示出来的功能名称。

之后在OrderAdmin类中增加一个新的actions属性,值是一个列表,放着对应的函数对象:

@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]
    actions = [exprot_to_csv]

然后到管理后台 http://127.0.0.1:8000/admin/orders/order/ 查看订单类,页面如下:

可以看到在actions里出现了一项新功能,就是导出到CSV,点击下载,就可以下载到一个文件。

注意这里作者的源代码中并没有在orders应用的models.py中为Order类的 meta 类增加 verbose_name 属性,所以文件名是download。手工增加verboser_name的值为’order’,这样才能下载到和原书里写的名称一样的order.csv文件。

CSV文件可以用纯文本编辑器如Notepad++或者IDE打开,可以看到里边的内容类似:

ID,first name,last name,email,address,postal
code,city,created,updated,paid,braintree id
3,Antonio,Melé,antonio.mele@gmail.com,Bank Street,WS
J11,London,25/02/2018,25/02/2018,True,2bwkx5b6

这样就实现了导出数据为CSV文件的功能,Django中将数据输出为CSV的说明可以参考官方文档

用自定义视图扩展管理后台的功能

之前我们使用了增加action的方法给管理后台增加了导出CSV文件的功能。有时候,可能需要对管理后台进行更多的自定义。Django的管理后台并不特殊,也是一个应用,非常易于扩展,可以更改源代码,模板样式,增添功能,甚至可以完全重新编写管理后台本身(从settings.py中将'django.contrib.admin'更换为自行编写的应用并配置URL)。唯一的要求就是管理站点所有的功能应当只允许管理员身份的用户进行登录或者做一些设置(比如只允许从本地地址登录管理后台)。

我们这次来修改一下管理后台,增加一个自定义的功能用于显示一个订单的信息。修改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装饰器顾名思义,就是只有User表里is_staff和is_active字段同时为True才能使用被装饰的视图。

然后配置orders应用的urls.py,增加一条路由:

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

然后建立上边视图所需的模板,新建 orders/templates/admin/orders/order/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 %}

首先我们继承django 管理后台的模板,CSS文件也采用django后台的样式表。然后根据母版里的block的名称分别来编写内容,有增加链接和用表格展示内容等。这里很多内容不一一解释,都是按照继承母版的要求以及django admin内部结构和urls来写的,具体可以参考django 2.1 中所有的内置模板

模板也可以不继承Django 的内置模板,完全重写。需要将自己编写的模板命名为与原来模板相同,然后替换原来的模板文件。当然,如果不想使用内置的admin模块,完全自行编写,就没有这个限制了。

最后,还需要将每个Object的链接都链到我们自己的视图函数上,编辑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])))

由于我们要渲染一段字符串当做HTML元素解析,所以必须使用mark_safe。这个函数的意思就是取得一个具体的Order对象然后生成一个链接到刚才编写的自定义视图函数,进入自定义模板。这个函数的名称就像一个字段名一样可以增加在页面显示中。需要在OrderAdmin中配置一下,修改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]
    list_filter = ['paid', 'created', 'updated']
    inlines = [OrderItemInline]
    actions = [exprot_to_csv]

然后启动站点,到 http://127.0.0.1:8000/admin/orders/order/ 查看,可以看到新增了一列:

点击View查看详情,会进入Django 管理后台风格的订单详情页:

动态生成PDF

本章的最后一个任务是实现通过订单动态的生成PDF发票信息。有很多Python库都可以用来生成PDF,常用的是Reportlab库,该库也是django 2.1 官方文档推荐使用的库。

Reportlab生成的PDF是黑白的,而且是直接从文本生成,无法带有样式。更好的做法是建立一个模板,然后将其转换成PDF,这样转换出来的PDF具有一定的格式,方便给用户观看。这里我们使用WeasyPrint库,这个库用来从HTML文件生成PDF文件。

安装WeasyPrint

Reportlab的安装不再赘述。WeasyPrint针对不同的系统需要一些依赖库,到其官方文档查看,例如在Windows上使用64位的Python,就需要安装GTK3-runtime64位版本,将GTK3的路径加入环境变量。

然后安装WeasyPrint:

pip install WeasyPrint==0.42.3

这是第三个到翻译为止还没有更新过的库。

建立PDF模板

这个PDF模板也就是我们打算转换成PDF的HTML文件。我们需要建立一个带有订单内容和CSS样式的模板,每次将最终生成的页面传给WeasyPrint生成PDF文件。

在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>。这个模板的内容很简单,就是将订单的用户信息和商品信息全部展示出来。之后我们要建立一个视图函数来渲染这个HTML文件。

建立视图渲染PDF文件

在orders应用的views.py中增加下列代码:

@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装饰器让这个功能只有超级用户可以使用
  • 通过order_id拿到订单对象,然后使用render_to_string方法将pdf.html加上订单信息渲染成字符串。
  • 设置HTTP请求头以便一会浏览器可以下载对应的pdf文件
  • 调用WeasyPrint,将渲染后的页面配上css文件,生成最终的PDF,写到HttpResponse里
  • 视图返回设置了附加PDF文件和文件名响应的请求给浏览器

细心的读者会发现settings.py中此时还没有STATIC_ROOT变量,这个变量是表示整个项目的静态文件的基础路径。而之前在模板里使用 load static标签,是指的应用目录下的static目录。在settings.py里设置该参数:

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

将其设置在项目根目录的static目录内。下一步比较有趣,打开终端或者Pycharm的执行manage.py命令:

python manage.py collectstatic

之后会看到:

122 static files copied to 'D:\Coding\sites\myshop\static'.

这个命令就是如果设置了STATIC_ROOT变量后,会把所有已经注册的应用里的static目录里的内容复制到项目根目录/static/文件下边来。还可以在settings.py里设置STATICFILES_DIRS,在执行该命令时就会连STATICFILES_DIRS内的目录中的文件一并复制过来。如果再次执行该命令,会提示是否覆盖原来的相同文件。

视图写好了,在orders应用的urls.py增加一条:

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

从一开始使用@staff_member_required大家可能就看出来了,像导出CSV一样,我们要再增加一个字段放生成PDF的链接。打开orders应用的admin.py,在OrderAdmin类之前增加:

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

order_pdf.short_description = 'Invoice'

还需给OrderAdmin的list_display增加这个新的字段:

@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链接,浏览器应该会下载一个名为order_xx.pdf的文件,如果是尚未支付的订单,样式如下:

已经支付的订单,则类似这样:

WeasyPrint的安装依赖比较多,原书里对这一块没有详细讲解,交给用户自己去进行。结果译者在windows环境折腾了好久,运行的时候依然找不到cairo库,后来在linux安装了N多依赖后终于运行成功。

使用电子邮件发送PDF文件

在之前我们使用Celery异步任务,在用户提交一个订单的时候异步发送邮件给用户。现在在支付成功的时候,我们发送带有这个PDF文件的邮件给用户。编辑payment应用中的views.py视图,导入一些新功能后编辑payment_process视图:

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

def payment_process(request):
    # ......
    if request.method == "POST":
        # ......
        # 如果提交交易信息成功,将已支付和交易id记录到Order表中
        if result.is_success:
            # ......
            order.save()

            # 建立邮件
            subject = 'My Shop - Invoice no. {}'.format(order.id)
            message = 'Please, find attached the invoice for your recent purchase.'
            email = EmailMessage(subject, message, 'admin@myshop.com', [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')
            # ......

这里使用WeasyPrint的方式和生成PDF文件的基本类似,唯一不同的就是没有向HttpResponse中写数据,而是向一个空的字节对象中写数据。由于PDF是一个二进制文件,所以就采用了这个方法。邮件我们使用了Django 的EmailMessage模块来实例化一个邮件对象,在有了二进制文件对象之后,调用email.attach方法,设置邮件附件中的文件名,内容和类型。

这就完成了发送邮件的工作。值得注意的是这里发送邮件是同步的,如果读者愿意,可以将发送邮件的代码分离,配置为使用Celery异步发送。

总结

这一章的实战性质越来越强,真正的集成了可以用的支付网关,以及让我们的网站对外提供信息的方式,除了展示页面之外,又多了生成CSV文件和PDF文件的方式。

本章的主要内容有:

  • Braintree支付网关沙盒测试的注册和使用
  • braintree模块的使用,使用braintree的JS代码生成前端页面
  • 给管理后台增加action的方法,通过管理类中增加actions属性,指定为按照规定自行编写的函数
  • 为管理后台中的模型增加自定义的功能字段
  • WeasyPrint的安装和使用
  • STATIC_ROOT变量的设置和collectstatic命令
  • django 发送邮件附加附件的方法