django by example 实践 myshop 项目(二)


点我查看本文集的说明及目录。


本项目相关内容包括:

实现过程

CH7 创建在线商店

CH8 管理支付和订单

CH9 扩展商店


CH8 支付和订单管理


上一章,我们新建了一个包含商品目录和订单系统的基础在线网站。我们还学习了使用 Celery 加载异步任务。本章,我们将学习如何在网站中集成支付网站,还将扩展 admin网站来管理订单并输出不同格式的订单。

本章包含以下内容:

  • 在项目中集成支付网关
  • 管理支付通知
  • 将订单输出到 CSV 文件
  • 为 admin网站创建自定义视图
  • 自动生成 PDF发货单

集成支付网关


支付网关可以实现用户在线支付。支付网关可以用来管理用户订单并将付款过程委托给可靠、安全的第三方。这意味着我们不需要在自己的系统中存储信用卡。

我们可以选择很多支付网关供应商提供的服务。这里将集成最流行的支付网关 Paypal。

PayPal 提供几种集成网关的方法。标准集成方法包括一个 Buy now 按钮(我们在其它网站看到过),按钮将用户重定向到 PayPal 处理支付过程。我们将在网站集成包含自定义 Buy now 按钮的 Paypal 标准支付。Paypal 将处理支付过程并向我们的服务器发送支付状态通知。

创建PayPal账号


在网站中集成 Paypal 需要 Paypal 商家账户,如果你没有 Paypal 账户,请在 https://www.paypal.com/c2/home 创建商家账户,如下图所示:

CH8-1.png

笔者注:

原文需要确保创建的是商家账户并选择 Paypal 标准解决方案,但笔者注册过程中没有选择 Paypal 标准方案的选项,因此只是进行了商家账户注册。

在注册表单中填入详细信息并完成注册, Paypal 将向你发送邮件来验证账户。

安装 django-paypal

Django-Paypal 是简化集成 Paypal 的第三方应用。我们使用它在商店中集成 Paypal 支付标准解决方案。django-paypal 的文档请查阅 http://django-paypal.readthedocs.io/en/stable/

在 shell 中运行以下命令来安装 django-paypal:

pip install django-paypal 

编辑项目的 settings.py 文件在 INSTALLED_APPS 中添加 'paypal.standard.ipn' :

INSTALLED_APPS = ['django.contrib.admin', 'django.contrib.auth',
                  'django.contrib.contenttypes', 'django.contrib.sessions',
                  'django.contrib.messages', 'django.contrib.staticfiles',
                  'shop', 'cart', 'orders', 'paypal.standard.ipn']

这是 django-paypal 提供的集成 Paypal 支付标准和即时支付通知(IPN) 的应用。我们稍后再处理付款通知。

在 myshop 的settings.py 文件中为 django-paypal 进行以下配置:

# django-paypal settings

PAYPAL_RECEIVER_EMAIL = 'mypaypalemail@myshop.com'
PAYPAL_TEST = True

这些设置包括:

  • PAYPAL_RECEIVER_EMAIL: 你的 Paypal 账户邮箱。使用创建 Paypal 账户时的邮箱代替 'mypaypalemail@myshop.com

  • PAYPAL_TEST:表示是否使用 Paypal 的沙盒环境来处理付款的布尔值。沙盒允许用户在迁移到生产环境之前测试 PayPal 集成。

打开 shell 并运行以下命令来同步 django-paypal 的模型:

python manage.py migrate

你应该可以看到这样的输出:

  Applying ipn.0001_initial... OK
  Applying ipn.0002_paypalipn_mp_id... OK
  Applying ipn.0003_auto_20141117_1647... OK
  Applying ipn.0004_auto_20150612_1826... OK
  Applying ipn.0005_auto_20151217_0948... OK
  Applying ipn.0006_auto_20160108_1112... OK
  Applying ipn.0007_auto_20160219_1135... OK
  Applying orders.0001_initial... OK

django-paypal 的模型现在已经同步到数据库了。我们还需要将 django-paypal 的 URL 模式添加到项目中。编辑 myshop 目录下的 urls.py 文件并添加以下 URL模式。记得将其放在 shop.urls 模式之前以避免错误的模式匹配:

url(r'paypal/',include('paypal.standard.ipn.urls')),

接下来,我们将支付网关添加到结算过程。

添加支付网关


结算过程这样工作:

  1. 将商品添加到购物车;

  2. 结算购物车中的商品;

  3. 重定向到 Paypal 进行支付;

  4. Paypal 向网站发送支付通知;

  5. Paypal 将用户重定向到网站。

使用以下命令在项目中新建一个应用:

python manage.py startapp payment

我们使用这个应用管理支付过程和用户支付。

编辑项目的 settings.py 文件并将 payment 添加到INSTALLED_APPS中:

INSTALLED_APPS = ['django.contrib.admin', 'django.contrib.auth',
                  'django.contrib.contenttypes', 'django.contrib.sessions',
                  'django.contrib.messages', 'django.contrib.staticfiles',
                  'shop', 'cart', 'orders', 'paypal.standard.ipn', 'payment']

payment 应用现在已经激活了,编辑 orders 应用的 views.py 文件并确认包含下面的 import :

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

将 order_create 视图中的下列代码:

# launch asynchronous task
order_created.delay(order.id)
return render(request, 'orders/order/created.html',
              {'order': order})

修改为:

# launch asynchronous task
order_created.delay(order.id)
# set the order in the session
request.session['order_id'] = order.id
# redirect to the payment
return redirect(reverse('payment:process'))

成功创建一个订单后,我们使用 order_id 会话关键词将订单 ID 设置到当前会话中。然后,重定向到 ‘payment:process’ URL 。

编辑 payment 应用的 views.py 文件并添加以下代码:

from decimal import Decimal

from django.conf import settings
from django.core.urlresolvers import reverse
from django.shortcuts import render, get_object_or_404
from orders.models import Order
from paypal.standard.forms import PayPalPaymentsForm


# Create your views here.

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

    paypal_dict = {'business': settings.PAYPAL_RECEIVER_EMAIL,
        'amount': '%.2f' % order.get_total_cost().quantize(Decimal('.01')),
        'item_name': 'Order {}'.format(order.id), 'invoice': str(order.id),
        'currency_code': 'USD',
        'notify_url': 'http://{}{}'.format(host, reverse('paypal-ipn')),
        'return_url': 'http://{}{}'.format(host, reverse('payment:done')),
        'cancel_return': 'http://{}{}'.format(host,
                                              reverse('payment:canceled')), }
    form = PayPalPaymentsForm(initial=paypal_dict)
    return render(request, 'payment/process.html',
                  {'order': order, 'form': form})

在 patment_process 视图中,我们生成了一个自定义 Paypal Buy now 表单来支付订单。首先,根据 order_id (刚刚在 order_create 视图中添加的)从会话中获得当前订单。获得给定 ID 的 Order 对象并新建一个包含以下字段的 PayPalPaymentForm 表单:

  • business:处理支付的 PayPal 商家账号。这里使用设置中定义的 PAYPAL_RECEIVER_EMAIL 邮箱账户。
  • amount:用户需要支付的总费用。
  • item_name:将要销售的商品名称,由于订单可能包含多种商品,这里使用订单 ID 。
  • invoice: 发货单 ID 。要求每笔支付的 invoice 唯一。这里我们使用订单 ID 。
  • currency_code:支付所用货币代码。我们将其设置为表示美元的 USD 。 需要与用户账户中设置的货币相同。
  • notify_url : Paypal 发送 IPN 请求的 URL 。我们使用 django-paypal 提供的 paypal-ipn URL 。 该 URL 对应的视图处理支付通知并保存到数据库。
  • return_url:支付成功后,用户跳转到的页面。我们使用后续将创建的 payment:done URL 。
  • cancel_return: 支付取消时,用户跳转到的页面。我们使用后续将创建的 payment:cancel URL 。

PayPalPaymentForm 渲染为包含隐藏字段的标准表单,用户仅能看到 Buy Now 按钮。当用户点击按钮时,表单将通过 POST 方式提交到 PayPal 。

接下来创建支付成功、支付取消时用户跳转到的简单视图。向同一个 views.py 文件添加以下代码:

from django.views.decorators.csrf import csrf_exempt


@csrf_exempt
def payment_done(request):
    return render(request, 'payment/done.html')


@csrf_exempt
def payment_canceled(request):
    return render(request, 'payment/canceled.html')

由于 PayPal 通过 POST 方式将用户重定向到上面的任意一个视图,这里使用 csrf_exempt 装饰器避免 Django 检验 CSRF 令牌。在 payment 应用目录下新建名为 urls.py 的文件并添加以下代码:

from django.conf.urls import url

from . import views

urlpatterns = [url(r'^process/$', views.payment_process, name='process'),
    url(r'^done/$', views.payment_done, name='done'),
    url(r'^canceled/$', views.payment_canceled, name='canceled'), ]

这是支付流程的 URLs。包含下面的 URL模式:

  • process:为 Paypal 表单生成 Buy Now 按钮的视图;
  • done:支付成功后,用户跳转到的页面;
  • canceled:支付取消后,用户跳转到的页面。

编辑 myshop 项目的 urls.py 文件并在 payment 应用中包含下面的 URL 模式:

url(r'payment/',include('payment.urls')),

记得将其放在 shop.urls 模式之前以避免错误的模式匹配。

在 payment 应用目录下创建下面的文件结构:

CH8-2.png

编辑 payment/process.html 模板并添加以下代码:

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

{% block title %}Pay using PayPal{% endblock %}

{% block content %}
  <h1>Pay using PayPal</h1>
  {{ form.render }}
{% endblock %}

这是渲染 PayPalPaymentForm 并展示 Buy Now 按钮的模板。

编辑 payment/done.html 模板并添加以下代码:

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

{% block content %}
    <h1>Your payment was successful</h1>
    <p>Your payment has been successfully received.</p>
{% endblock %}

这是用户支付成功后跳转到的页面的模板。

编辑 payment/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 %}

这是用户由于某些原因取消支付后跳转到的页面的模板。

使用 PayPal 沙盒

在浏览器中打开 https://developer.paypal.com/ 并使用 Paypal 商家账户登录。点击 Dashboard 目录项,并点击左侧目录中 Sandbox 下的 accounts 选项,应该可以看到沙盒测试用户列表:

CH8-3.png

我们可以看到 Paypal 自动创建的商业账户和个人账户。我们还可以使用 Create Accounts 按钮创建新的沙盒测试账户。

点击列表中个人账户 Email Address ,会出现 Profile 和 notifications 链接,点击 Profile 链接。你将看到测试账户的 e-mail 和用户信息等消息:


CH8-4.png

在 Funding 选项中,我们可以找到银行账户、信用卡信息和 Paypal 信用账单。

测试账户可以使用沙盒环境在网站上进行支付。回到 Profile 选项并点击 Change password 链接,为两个测试账户创建自定义密码。

打开 shell 使用 python manage.py runserver 命令启动开发服务器。在浏览器中打开 http://127.0.0.1:8000,向购物车中添加一些商品,并填写结账表单。当你点击 Place order 按钮,订单将保存到数据库,订单 ID 将保存到当前会话( session )中,你将重定向到支付页面。这个页面从会话中获取订单并将 Paypal 表单渲染为 Buy Now 按钮:

CH8-5.png

我们可以通过 HTML 源码查看生成的表单字段。

笔者注:

这里一定要确保第七章中的 rabbitmq-server 和 celery 处于运行状态。

点击 Buy Now 按钮,你将重定向到 PayPal ,你将看到下面的页面:


CH8-6.png

输入沙盒中 buyer 账户的 e-mail 和密码并点击 Log In 按钮,你将重定向到下面的页面:

CH8-7.png

现在,点击立即付款按钮。最终,你将看到包含交易 ID 的确认页面,页面看起来是这样的:


CH8-9.png

点击返回商家按钮。你将重定向 PayPalPaymentsForm 表单的 return_url 字段指定的 URL。这是 payment_done 视图的 URL。页面看起来这样:

CH8-10.png

支付已经成功了。然而, PayPal 无法将支付状态通知发送到我们的应用。这是由于我们的项目运行在外部无法访问的 127.0.0.1 上。下面我们将学习如何在互联网上访问我们的网站并能获得 IPN 通知。

获得支付通知


许多支付网关使用 IPN 提供的实时追踪支付情况。网关处理完一个付款后会实时向网站服务器发送一个通知。这个通知包含状态、支付签名(帮助我们确认通知是否是原始通知)等所有支付细节。网关使用独立的 HTTP 请求向服务器发送这条通知。至于连接问题,Paypal 将多次尝试通知网站。

django-paypal 应用包含两种 IPNs 信号,这些信号包括:

  • valid_ipn_received: 从 Paypal 接收到的消息正确并且尚未保存到数据库时触发的信息

  • invalid_ipn_received: 从 Paypal 接收到无效数据或者格式不正确时触发的消息

我们将创建一个自定义接收函数并将其连接到 valid_ipn_received 信号来确定支付。

在 payment 应用目录下新建一个 signals 的文件夹,文件夹内新建 __init__.py 和 handlers.py 的文件,并在 handlers.py 中包含以下代码:

from django.shortcuts import get_object_or_404
from paypal.standard.ipn.signals import valid_ipn_received
from paypal.standard.models import ST_PP_COMPLETED

from orders.models import Order


def payment_notification(sender, **kwargs):
    ipn_obj = sender
    if ipn_obj.payment_status == ST_PP_COMPLETED:
        # payment was successful
        order = get_object_or_404(Order, id=ipn_obj.invoice)
        # mark the order as paid
        order.paid = True
        order.save()


valid_ipn_received.connect(payment_notification)

这里将 payment_notification 接收函数连接到 django-paypal 提供的 valid_ipn_received 信号,接收函数的工作原理为:

  1. 接收发送的对象,该对象是 paypal.standard.ipn.models 中定义的 PayPalIPN 模型的实例;

  2. 检查 payment_status 属性是否与 django-paypal 的完成属性一致。 django-paypal 的完成属性表示支付成功;

  3. 使用快捷函数 get_object_or_404() 获得发送给 Paypal 的订单对象。

  4. 将订单对象的 paid 属性设置为 True ,表示支付成功。

我们需要确保加载了信号,这样 valid_ipn_received 触发时才能调用接收函数。加载信息的最佳实践是加载包含信号的应用。我们可以通过下一节讲到的定义自定义应用配置来实现上述功能。

笔者注:

与第六章的配置 signals 时的原因相同。我们这里使用了 signals 文件夹。

配置应用


我们已经在第六章了解了如何配置应用,现在为 payment 应用设置自定义配置来加载我们的信息接收函数。

在我们的 payment 应用目录的 apps.py 中添加以下代码:

from django.apps import AppConfig


class PaymentConfig(AppConfig):
    name = 'payment'

    def ready(self):
        # import signal handlers
        from .signals import handler

我们在 ready 方法中加载信号模块,从而在应用初始化的过程中加载信号模块。

编辑 payment 应用的__init__.py文件并添加以下代码:

default_app_config = 'payment.apps.AppConfig'

笔者注:

python 3.3 以上版本的模块可以不使用 __init__.py 文件,因此,最好在 INSTALLED_APPS 中使用 'payment.apps.AppConfig',而不是在 __init__.py 文件中定义。

这样,Django 将自动加载自定义应用配置类。关于应用配置的更多信息详见 https://docs.djangoproject.com/en/1.11/ref/applications/

测试支付通知


由于在本地环境工作,我们需要确保网站可以被 Paypal 访问。有一些应用可以实现因特网访问开发环境,我们将使用最流行的 Ngrok 。

https://ngrok.com/ 为操作系统下载 Ngrok 并在 shell 中(Ngrok 所在的文件夹中)使用以下命令运行:

./ngrok http 8000

这个命令告诉 Ngrok 在 8000 端口为本地主机创建一个通道并为其设置一个网络可以访问的主机名称。你看到的输出应该与下面的输出类似:



Session Status                online                                            

Session Expires               7 hours, 59 minutes                               

Version                       2.2.8                                             

Region                        United States (us)                                

Web Interface                 http://127.0.0.1:4040                             

Forwarding                    http://4c94fca6.ngrok.io -> localhost:8000        

Forwarding                    https://4c94fca6.ngrok.io -> localhost:8000       

                                                                                

Connections                   ttl     opn     rt1     rt5     p50     p90       

                              0       0       0.00    0.00    0.00    0.00  

Ngrok 告诉我们使用开发服务器运行在本地 8000 端口的网站可以分别通过 http://4c94fca6.ngrok.iohttps://4c94fca6.ngrok.io 在网络上进行访问。Ngrok 还提供一个 URL 来访问 web 接口(展示发送到服务器的请求消息)。

使用浏览器打开 Ngrok 提供的 URL(测试使用的是上面的 http://4c94fca6.ngrok.io ),向购物车添加几件商品,下单,并使用 Paypal 测试账户进行支付。这次,Paypal 将能够访问为 payment_process 视图的 PaypalPaymentForm 的notify_url 字段生成的 URL 。如果你看下渲染的表单,你将看到HTML 表单字段是这样的:

<input id="id_notify_url" name="notify_url" type="hidden" value="http://4c94fca6.ngrok.io/paypal/">

笔者注:

使用 http://4c94fca6.ngrok.io 需要将 ‘4c94fca6.ngrok.io’ 和 ‘127.0.0.1 加入到项目 settings.py 文件中的ALLOWED_HOSTS 中。

完成支付过程后,在浏览器中打开http://127.0.0.1:8000/admin/ipn/paypalipn/。你将会看到一个上次支付的 IPN 对象,它的状态为 Completed 。 这个对象包含支付的所有信息,这些信息由 PayPal 发送你提供的接收 IPN 通知的 URL 。IPN admin 列表页面看起来是这样的:

CH8-11.png

我们也可以使用 Paypal 的 IPN 仿真器加载 IPN ,仿真器地址为https://developer.paypal.com/developer/ipnSimulator/ 。仿真器可以指定字段和发送的通知类型。

除了 PayPal Payment Standard,PayPal 提供订阅服务 Website Payments Pro,它可以接受网站付款而无需将用户重定向到PayPal。你可以在http://django-paypal.readthedocs.io/en/v0.4.1/pro/index.html了解如何集成 Website Payments Pro。

将订单导出到 CSV 文件


有时,我们希望将模型中的数据导出到文件中,以便于在其它系统中使用。导出/导入数据最常用的格式为CSV。 CSV 文件是包含记录的纯文本文件。文件中通常一行为一条记录,记录的字段通过一些分隔字符(通常使用逗号)进行分隔。

向 admin网站添加自定义动作


Django提供自定义 admin网站的一些选项。我们将为对象列表视图增加一些自定义 admin动作。

admin动作的工作方式如下:用户在 admin 对象列表页面使用选择框选择对象,然后选择对选中对象执行的操作,最后执行操作。下面的截图展示了 admin 网站中动作的位置。

CH8_CSV_1.png

注意:

创建自定义 admin动作可以帮助用户同时对多个对象进行操作。

我们可以通过创建接收以下参数的常规函数来创建自定义动作:

  • 需要展示的当前 ModelAdmin;
  • 当前请求对象( HttpRequest 实例);
  • 表示用户选择的对象的 QuerySet ;

我们将创建一个自定义 admin动作来下载订单列表。编辑 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]
    # Write a first row with header information
    writer.writerow([field.verbose_name for field in fields])
    # Write data rows
    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. 创建了一个包含自定义 text/csv 内容类型的 HttpResponse 实例通知浏览器响应是一个 CSV文件。我们还添加了Content-Disposition 标头表示 HTTP 响应包含一个附加文件;
  2. 创建了一个 CSV writer 对象写入 response 对象;
  3. 使用模型 _meta 选项的 get_fields() 方法动态获取 model 字段。这里排除了多对多和一对多关系。
  4. 将字段名称作为标题行写入文件;
  5. 对指定的 QuerySet 进行迭代,QuerySet 返回的每个对象为一行;这里处理 datetime 格式是由于 CSV 的输出值必须是字符串格式。
  6. 通过设置函数的 short_description 属性自定义模板中操作动作的名称。

我们已经创建了一个可以添加到任意 ModelAdmin 类的通用 admin 动作。

最后,将新的 export_to_csv admin 动作添加到 OrderAdmin 中,如下所示:

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 = [export_to_csv]

在浏览器中打开http://127.0.0.1:8000/admin/orders/order/,将会看到以下页面:

CH8_CSV_2.png

选中一些订单并从动作选择框中选择 Export to CSV 动作并点击 Go 按钮,浏览器将下载名为 order.csv 的 CSV文件。使用文本编辑器打开下载的文件。你应该可以看到以下格式的内容,包含标题行和选中的 Order 对象:

ID,first name,last name,email,address,postal code,city,created,updated,paid
1,***,***,***@163.com,*******,*****,****,24/02/2018,24/02/2018,True

我们可以发现,创建 admin动作非常简单。

使用自定义视图扩展 admin网站


有时,我们需要自定义 admin网站的范围会超出 ModelAdmin 配置的范围,比如创建 admin动作、覆盖 admin模板等。这种情况下,需要创建一个自定义 admin 视图。可以使用自定义视图来实现任何需要的功能。需要确认的是只有员工才能访问视图以及通过扩展 admin模块维持 admin 的统一风格。

我们创建自定义视图来展示订单的信息。编辑 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_active 和 is_staff 字段是否均为 True 。在这个视图中,我们通过给定的 id 获得 Order 对象并渲染一个模板来展示订单。

现在,编辑 orders 应用的 urls.py 文件并添加以下 URL 模式:

url(r'^admin/order/(?P<order_id>\d+)/$',
    views.admin_order_detail, name='admin_order_detail'),

在 orders 应用的 templates/ 目录下创建如下的文件结构:


CH8_CSV_3.png

编辑 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> &rsaquo;
        <a href="{% url "admin:orders_order_changelist" %}">Orders</a>
        &rsaquo;
        <a href="{% url "admin:orders_order_change" order.id %}">Order {{ order.id }}</a>
        &rsaquo; 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>
                <h2>Items bought</h2>
                <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 网站展示订单详情的模板。模板扩展 Django admin 网站 admin/base_site.html 模板,这个模板包含 admin 网站的 HTML 结构和 CSS 样式。我们加载了自定义静态文件 css/admin.css。

为了使用静态文件,需要获得本章代码中的相应静态文件。拷贝 orders 应用下的 static 目录下的静态文件并将它们添加到你的目录中。

我们使用父模板中定义的 blocks 来添加自定义内容,包括展示订单和订单中的商品信息。

扩展 admin模板需要了解它的结构并识别存在的 blocks 。我们可以从这里找到所有的 admin 模板https://github.com/django/django/tree/master/django/contrib/admin/templates/admin

如果需要,还可以覆盖 admin模板。覆盖 admin模板只需要将它拷贝到 templates 目录下并保持相同的相对路径和文件名。Django 的 admin网站将使用自定义模板覆盖默认模板。

最后,我们为 admin 网站列表页面的每个 Order 对象添加链接。编辑 orders 应用的 admin.py 文件并在 OrderAdmin 类之前添加以下代码:

from django.core.urlresolvers import reverse

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


order_detail.allow_tags = True

这是输入 Order 对象为参数并返回 admin_site_detail URL 链接的函数。Django 默认转义 HTML 输出。我们需要设置 这个可调用函数的 allow_tags 属性为 True 来防止自动转义。

注意:

将 allow_tags 属性设置为 True 将防止任意模型、ModelAdmin 方法及其他可调用函数的 HTML 转义。确保转义用户的输入以防止跨网站脚本。

然后,编辑 Order Admin 网站展示链接:

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 列:

CH8_CSV_4.png

点击任意订单的 View 链接加载自定义订单详情页面。你应该可以看到下面的页面。


CH8_CSV_5.png

动态生成PDF通知


现在已经有了结算和支付系统,我们还可以为每个订单生成 PDF 发货单。生成 PDF文件的 Python 库有很多,比较受欢迎的是 Reportlab ,我们可以从这里找到使用 Reportlab 输出 PDF 文件的方法 https://docs.djangoproject.com/en/1.11/howto/outputting-pdf/

大多数情况需要为 PDF 文件添加自定义样式和格式。我们可以发现渲染 HTML 模板然后将其转换为 PDF更加方便,这样可以使 Python 远离表示层 。我们将使用这个方法并使用一个模块在 django 中生成 PDF 文件。我们将使用 WeasyPrint ,WeasyPrint 是将 HTML 模板转换为 PDF 文件的 Python 库文件。

安装 WeasyPrint


首先,从 http://weasyprint.readthedocs.io/en/latest/install.html 为系统安装 WeasyPrint 依赖程序。

然后,使用 pip 安装WeasyPrint:

pip install WeasyPrint

创建PDF模板


WeasyPrint 输入一个 HTML文件。我们将创建一个 HTML 模板,使用 Django 进行渲染,然后将其传入 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>

这是 PDF 发货单的模板。在模板中,我们展示了订单详情和一个订单中的商品 <table>,以及订单是否支付的信息。

渲染 PDF 文件


我们将使用 admin网站创建一个视图来生成已有订单的 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_{}.pdf"'.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 对象,然后使用 Django 提供的 render_to_string() 函数渲染 orders/order/pdf.html 。渲染的 HTML 保存到 html 变量中,然后生成一个 application/pdf 格式的 HttpResponse 对象,该对象包含指定文件名的 Content-Disposition 头。我们使用 WeasyPrint 从渲染的 HTML 生成 PDF 文件并将文件写到 HttpResponse 对象中。 css/pdf.css 为生成 PDF 文件添加 CSS 格式。这里使用 STATIC_ROOT 设置从本地路径加载静态文件。 最后,返回生成的响应。

由于需要使用 STATIC_ROOT 设置,我们将其添加到项目中,它是项目存放静态文件的路径。编辑 myshop 项目的 settings.py 文件并添加以下代码:

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

然后,运行命令 python mange.py collectstatic 。你应该可以看到项目使用的所有的静态文件都被拷贝到了 STATIC_ROOT 目录下了。

collectstatic 命令将项目应用的静态文件拷贝到 STATIC_ROOT 设置的目录下。每个应用都可以使用 static 目录提供自己的静态文件。此外,还可以在 STATICFILE_DIRS 设置的路径中提供额外的静态文件。执行 collectstatic 命令时 STATICFILE_DIRS 指定的所有目录都将被拷贝到 STATIC_ROOT 设置的目录下。

编辑 orders 应用的 urls.py 文件并添加以下 URL 模式:

url(r'^admin/order/(?P<order_id>\d+)/pdf/$',
    views.admin_order_pdf, name='admin_order_pdf'),

现在,我们可以编辑 Order 模型的admin 列表展示页面来为条记录添加生成 PDF 文件的链接。编辑 orders 应用的 admin.py 文件,在 OrderAdmin 类之前添加以下代码:

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


order_pdf.allow_tags = True
order_pdf.short_description = 'PDF bill' 

将 order_pdf 添加到 OrderAdmin 的 list_display 属性中:

list_display = ['id', 'first_name', 'last_name', 'email', 'address',
                'postal_code', 'city', 'paid', 'created', 'updated',
                order_detail, order_pdf]

如果调用函数指定了 short_description,Django 将使用它作为列名,否则使用函数名。

在浏览器中打开 http://127.0.0.1:8000/admin/orders/order/,将会看到下面的页面:

CH8-12.png

点击任意订单的 PDF 链接,没有付款的订单将会显示下面这样的 PDF 文件:

CH8-13.png

已经付款的订单将会显示下面这样的 PDF 文件:

CH8-14.png

通过email 发送PDF文件

客户付款后,我们向客户发送包含 PDF 发货单的邮件。编辑 payment 应用 signals 中的 handlers.py 文件并导入下面库文件:

from io import BytesIO

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

然后,在 order.save() 后面添加下面的代码:

# create invoice e-mail
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])

# generate 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)
# attach PDF file
email.attach('order_{}.pdf'.format(order.id), out.getvalue(),
             'application/pdf')
# send e-mail
email.send()

这里,我们使用 Django 提供的 EmailMessage 类创建一个 e-mail 对象,然后渲染模板并保存到 html 变量中,并根据渲染的模板生成 PDF文件并输出到内存字节缓冲区 BytesIO 实例中。这时我们可以使用 attach 方法将生成的 PDF文件以附件的形式放到 EmailMessage 对象中。

发送邮件需要在项目的 settings.py 文件中设置 SMTP, SMTP 配置在第二章中介绍过。

现在,打开 Ngrok 为我们的应用提供的 URL 并完成一个新的支付过程,从而接收 PDF 发货单邮件。

总结


本章,我们在项目中集成了支付网关,自定义了 Django admin网站,并且学习了如何动态生成 CSV 和 PDF 文件。

下一章,我们将介绍 Django 项目的全球化和本地化,还将创建折扣系统和商品推荐引擎。

推荐阅读更多精彩内容