...

Django2实战示例 第八章 管理支付与订单

目录

Django2实战示例 第一章 创建博客应用
Django2实战示例 第二章 增强博客功能
Django2实战示例 第三章 扩展博客功能
Django2实战示例 第四章 创建社交网站
Django2实战示例 第五章 内容分享功能
Django2实战示例 第六章 追踪用户行为
Django2实战示例 第七章 创建电商网站
Django2实战示例 第八章 管理支付与订单
Django2实战示例 第九章 扩展商店功能
Django2实战示例 第十章 创建在线教育平台
Django2实战示例 第十一章 渲染和缓存课程内容
Django2实战示例 第十二章 创建API
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,如下图所示:

image

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

image

这些信息与使用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:process URL,这个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请求,获取交易token payment_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,像下图所示:

image

可以看一下页面的HTML代码,从而理解什么是Hosted Fields。

针对沙盒测试环境,Braintree提供了一些测试用的信用卡资料,可以进行付款成功或失败的测试,可以在https://developers.braintreepayments.com/guides/credit-cards/testing-go-live/python找到,我们来使用4111 1111 1111 1111这个信用卡号,在CVV码中填入123,到期日期填入未来的某一天比如12/20

image

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

image

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

image

译者注:Braintree网站在成书后有部分改版,读者看到的支付详情页面可能与上述图片有一些区别。

然后再查看管理站点http://127.0.0.1:8000/admin/orders/order/中的对应记录,该订单应该已经被标记为已支付,而且记录了交易ID,如下图所示:

image

我们现在就成功集成了支付功能。

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的位置如下图所示:

image

创建自定义管理行为可以让管理员批量对记录进行操作。

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

  • 当前显示的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/查看订单类,页面如下:

image

选择一些订单,然后选择上边的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é,antonio.mele@gmail.com,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/,可以看到新增了一列:

image

点击任意一个订单的View链接查看详情,会进入Django管理后台风格的订单详情页:

image

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:

image

点击PDF链接,浏览器应该会下载一个PDF文件,如果是尚未支付的订单,样式如下:

image

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

image

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, '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')
        else:
            return redirect('payment:canceled')
    else:
        # ......

这里使用了EmailMessage类创建一个邮件对象email,然后将模板渲染到html变量中,然后通过WeasyPrint将其写入一个BytesIO二进制字节对象,之后使用attach方法,将这个字节对象的内容设置为EmailMessage的附件,同时设置文件类型为PDF,最后发送邮件。

记得在settings.py中设置SMTP服务器,可以参考第二章。

现在,可以尝试完成一个新的付款,并且在邮箱内接收PDF发票。

总结

在这一章中,我们集成了支付网关,自定义了Django管理后台,还学习了如何将数据以CSV文件格式导出和动态生成PDF文件。

下一章我们将深入了解Django项目的国际化和本地化设置,还将创建一个优惠码功能和商品推荐引擎。

posted @ 2021-10-22 17:44  韩志超  阅读(346)  评论(0编辑  收藏  举报