欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

第八章 管理支付和订单

程序员文章站 2024-01-31 12:15:28
...

8 管理支付和订单

在上一章中,你创建了一个包括商品目录和订单系统的在线商店。你还学习了如何用Celery启动异步任务。在这一章中,你会学习如何在网站中集成支付网关。你还会扩展管理站点,用于管理订单和导出不同格式的订单。

我们会在本章覆盖以下知识点:

  • 在项目中集成支付网关
  • 管理支付通知
  • 导出订单到CSV文件中
  • 为管理站点创建自定义视图
  • 动态生成PDF单据

8.1 集成支付网关

支付网关允许你在线处理支付。你可以使用支付网关管理用户订单,以及通过可靠的,安全的第三方代理处理支付。这意味着你不用考虑在自己的系统中存储信用卡。

有很多支付网关可供选择。我们将集成PayPal,它是最流行的支付网关之一。

PayPal提供了几种方法在网站中集成它的网关。标准集成包括一个Buy now按钮,你可能在其它网站见过。这个按钮把顾客重定向到PayPal来处理支付。我们将在网站中集成包括一个自定义Buy now按钮的PayPal Payments Standard。PayPal会处理支付,并发送一条支付状态的信息到我们的服务器。

8.1.1 创建PayPal账户

你需要一个PayPal商家账户,才能在网站中集成支付网关。如果你还没有PayPal账户,在这里注册。确保你选择了商家账户。

在注册表单填写详细信息完成注册。PayPal会给你发送一封邮件确认账户。

8.1.2 安装django-paypal

django-paypal是一个第三方Django应用,可以简化在Django项目中集成PayPal。我们将用它在我们的商店中集成PayPal Payments Standard。你可以在这里查看django-paypal的文档。

在终端使用以下命令安装django-paypal:

pip install django-paypal

编辑项目的settings.py文件,在INSTALLED_APPS设置中添加paypal.standard.ipn

INSTALLED_APPS = [
    # ...
    'paypal.standard.ipn',
]

这个应用是django-paypal提供的,通过Instant Payment Notification(IPN)集成PayPal Payments Standard。我们之后会处理支付通知。

myshopsettings.py文件添加以下设置来配置django-paypal:

# django-paypal settings
PAYPAL_RECEIVER_EMAIL = 'aaa@qq.com'
PAYPAL_TEST = True

这些设置分别是:

  • PAYPAL_RECEIVER_EMAIL:你PayPal账户的邮箱地址。用你创建PayPal账户的邮箱替换aaa@qq.com
  • PAYPAL_TEST:一个布尔值,表示是否用PayPal的Sandbox环境处理支付。在迁移到生产环境之前,你可以用Sandbox测试PayPal集成。

打开终端执行以下命令,同步django-paypal的模型到数据库中:

python manage.py migrate

你会看到类似这样结尾的输出:

Running migrations:
  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

现在django-paypal的模型已经同步到数据库中。你还需要添加django-paypal的URL模式到项目中。编辑myshop项目的主urls.py文件,并添加以下URL模式。记住,把它放在shop.urls模式之前,避免错误的模式匹配:

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

让我们把支付网关添加到结账过程中。

8.1.3 添加支付网关

结账流程是这样的:

  1. 用户添加商品到购物车中。
  2. 用户结账购物车。
  3. 重定向用户到PayPal进行支付。
  4. PayPal发送支付通知到我们的服务器。
  5. PayPal重定向用户返回我们的网站。

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

python manage.py startapp payment

我们将使用这个应用管理结账流程和用户支付。

编辑项目的settings.py文件,在INSTALLED_APP设置中添加payment

INSTALLED_APPS = [
    # ...
    'paypal.standard.ipn',
    'payment',
]

现在payment应用已经在项目中**了。编辑orders应用的views.py文件,添加以下导入:

from django.shortcuts import render, redirect
from django.core.urlresolvers import reverse

找到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)
request.session['order_id'] = order.id
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 paypal.standard.forms import PayPalPaymentsForm
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)
    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})

payment_process视图中,我们生成了一个自定义PayPal的Buy now按钮用于支付。首先我们从order_id会话键中获得当前订单,这个键值之前在order_create视图中设置过。我们获得指定ID的Order对象,并创建了包括以下字段的PayPalPaymentForm

  • business:处理支付的PayPal商家账户。在这里我们使用PAYPAL_RECEIVER_EMAIL设置中定义的邮箱账户。
  • amount:向顾客收取的总价。
  • item_name:出售的商品名。我们使用商品ID,因为订单里可能包括多个商品。
  • invoice:单据ID。每次支付对应的这个ID应用是唯一的。我们使用订单ID。
  • currency_code:这次支付的货币。我们设置为USD使用美元。使用与PayPal账户中设置的相同货币(EUR对应欧元)。
  • notify_url:PayPal发送IPN请求到这个URL。我们使用django-paypal提供的paypal-ipn URL。这个URL关联的视图处理负责支付通知和在数据库中保存支付通知。
  • return_url:支付成功后重定向用户到这个URL。我们使用之后会创建的payment:done URL。
  • cancel_return:如果支付取消,或者遇到其它问题,重定向用户到这个URL。我们使用之后会创建的payment:canceled URL。

PayPalPaymentForm会被渲染为带隐藏字典的标准表单,用户只能看到Buy now按钮。点用户点击这个按钮,表单会通过POST提交到PayPal。

让我们创建一个简单的视图,当支付完成,或者因为某些原因取消支付,让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'),
]

这些是支付流程的URL。我们包括了以下URL模式:

  • process:用于生成带Buy now按钮的PayPal表单的视图
  • done:当支付成功后,用于PayPal重定向用户
  • canceled:当支付取消后,用于PayPal重定向用户

编辑myshop项目的主urls.py文件,引入payment应用的URL模式:

url(r'^payment/', include('payment.urls', namespace='payment')),

记住把它放在shop.urls模式之前,避免错误的模式匹配。

payment应用目录中创建以下文件结构:

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

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

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

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

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

这个模板用于渲染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 content %}

用户支付成功后,会重定向到这个模板页面。

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

处理支付遇到问题,或者用户取消支付时,会重定向到这个模板页面。

让我们尝试完整的支付流程。

8.1.4 使用PayPal的Sandbox

在浏览器中打开http://developer.paypal.com,并用你的PayPal商家账户登录。点击Dashboard菜单项,然后点击Sandbox下的Accounts选项。你会看到你的sandbox测试账户列表,如下图所示:

第八章 管理支付和订单

最初,你会看到一个商家账户和一个PayPal自动生成的个人测试账户。你可以点击Create Account按钮创建新的sandbox测试账户。

点击列表中TypePERSONAL的账户,然后点击Pofile链接。你会看到测试账户的信息,包括邮箱地址和个人资料信息,如下图所示:

第八章 管理支付和订单

Funding标签页中,你会看到银行账户,信用卡数据,以及PayPal贷方余额。

当你的网站使用sandbox环境时,测试账户可以用来处理支付。导航到Profile标签页,然后点击修改Change password链接。为这个测试账户创建一个自定义密码。

在终端执行python manage.py runserver命令启动开发服务器。在浏览器中打开http://127.0.0.1:8000/,添加一些商品到购物车中,然后填写结账表单。当你点击Place order按钮时,订单会存储到数据库中,订单ID会保存在当前会话中,然后会重定向到支付处理页面。这个页面从会话中获得订单,并渲染带Buy now按钮的PayPal表单,如下图所示:

第八章 管理支付和订单

译者注:启动开发服务器后,还需要启动RabbitMQ和Celery,因为我们要用它们异步发送邮件,否则会抛出异常。

你可以看一眼HTML源码,查看生成的表单字段。

点击Buy now按钮。你会被重定向到PayPal,如下图所示:

第八章 管理支付和订单

输入顾客测试账号的邮箱地址和密码,然后点击登录按钮。你会被重定向到以下页面:

第八章 管理支付和订单

译者注:即之前修改过密码的个人账户。

现在点击立即付款按钮。最后,你会看到一个包括交易ID的确认页面,如下图所示:

第八章 管理支付和订单

点击返回商家按钮。你会被重定向到PayPalPaymentFormreturn_url字段指定的URL。这是payment_done视图的URL,如下图所示:

第八章 管理支付和订单

支付成功!但是因为我们在本地运行项目,127.0.0.1不是一个公网IP,所以PayPal不能给我们的应用发送支付状态通知。我们接下来学习如何让我们的网站可以从Internet访问,从而接收IPN通知。

8.1.5 获得支付通知

IPN是大部分支付网关都会提供的方法,用于实时跟踪购买。当网关处理完一个支付后,会立即给你的服务器发送一个通知。该通知包括所有支付细节,包括状态和用于确认通知来源的支付签名。这个通知作为独立的HTTP请求发送到你的服务器。出现问题的时候,PayPal会多次尝试发送通知。

django-payapl自带两个不同的IPN信号,分别是:

  • valid_ipn_received:当从PayPal接收的IPN消息是正确的,并且不会与数据库中现在消息重复时触发
  • invalid_ipn_received:当从PayPal接收的消息包括无效数据或者格式不对时触发

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

payment应用目录中创建signals.py文件,并添加以下代码:

from django.shortcuts import get_object_or_404
from paypal.standard.models import ST_PP_COMPLETED
from paypal.standard.ipn.signals import valid_ipn_received
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. 我们接收sender对象,它是在paypal.standard.ipn.models中定义的PayPalPN模型的一个实例。
  2. 我们检查paypal_status属性,确保它等于django-paypal的完成状态。这个状态表示支付处理成功。
  3. 接着我们用get_object_or_404快捷函数获得订单,这个订单的ID必须匹配我们提供给PayPal的invoice参数。
  4. 我们设置订单的paid属性为True,标记订单状态为已支付,并把Order对象保存到数据库中。

valid_ipn_received信号触发时,你必须确保信号模块已经加载,这样接收函数才会被调用。最好的方式是在包括它们的应用加载的时候,加载你自己的信号。可以通过定义一个自定义的应用配置来实现,我们会在下一节中讲解。

8.1.6 配置我们的应用

你已经在第六章学习了应用配置。我们将为payment应用定义一个自定义配置,用来加载我们的信号接收函数。

payment应用目录中创建apps.py文件,并添加以下代码:

from django.apps import AppConfig

class PaymentConfig(AppConfig):
    name = 'payment'
    verbose_name = 'Payment'

    def ready(self):
        # improt signal handlers
        import payment.signals

在这段代码中,我们为payment应用定义了一个AppConfig类。name参数是应用的名字,verbose_name是一个可读的名字。我们在ready()方法中导入信号模板,确保应用初始化时会加载信号模块。

编辑payment应用的__init__.py文件,并添加这一行代码:

default_app_config = 'payment.apps.PaymentConfig'

这会让Django自动加载你的自定义应用配置类。你可以在这里阅读更多关于应用配置的信息。

8.1.7 测试支付通知

因为我们在本地环境开发,所以我们需要让PayPal可以访问我们的网站。有几个应用程序可以让开发环境通过Internet访问。我们将使用Ngrok,是最流行的之一。

这里下载你的操作系统版本的Ngrok,并使用以下命令运行:

./ngrok http 8000

这个命令告诉Ngrok在8000端口为你的本地主机创建一个链路,并为它分配一个Internet可访问的主机名。你可以看到类似这样的输入:

Session Status                online
Account                       lakerszhy (Plan: Free)
Update                        update available (version 2.2.4, Ctrl-U to update)
Version                       2.1.18
Region                        United States (us)
Web Interface                 http://127.0.0.1:4040
Forwarding                    http://c0f17d7c.ngrok.io -> localhost:8000
Forwarding                    https://c0f17d7c.ngrok.io -> localhost:8000

Connections                   ttl     opn     rt1     rt5     p50     p90
                              0       0       0.00    0.00    0.00    0.00

Ngrok告诉我们,我们网站使用的Django开发服务器在本机的8000端口运行,现在可以通过http://c0f17d7c.ngrok.iohttps://c0f17d7c.ngrok.io(分别对应HTTP和HTTPS协议)在Internet*问。Ngrok还提供了一个网页URL,这个网页显示发送到这个服务器的信息。在浏览器中打开Ngrok提供的URL,比如http://c0f17d7c.ngrok.io。在购物车中添加一些商品,下单,然后用PayPal测试账户支付。此时,PayPal可以访问payment_process视图中PayPalPaymentFormnotify_url字段生成的URL。如果你查看渲染的表单,你会看类似这样的HTML表单:

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

完成支付处理后,在浏览器中打开http://127.0.0.1:8000/admin/ipn/paypalipn/。你会看到一个IPN对象,对应状态是Completed的最新一笔支付。这个对象包括支付的所有信息,它由PayPal发送到你提供给IPN通知的URL。

译者注:如果通过http://c0f17d7c.ngrok.io访问在线商店,则需要在项目的settings.py文件的ALLOWED_HOSTS设置中添加c0f17d7c.ngrok.io

译者注:我在后台看到的一直都是Pending状态,一直没有找出原因。哪位朋友知道的话,请给我留言,谢谢。

你也可以在这里使用PayPal的模拟器发送IPN。模拟器允许你指定通知的字段和类型。

除了PayPal Payments Standard,PayPal还提供了Website Payments Pro,它是一个订购服务,可以在你的网站接收支付,而不用重定向到PayPal。你可以在这里查看如何集成Website Payments Pro

8.2 导出订单到CSV文件

有时你可能希望把模型中的信息导出到文件中,然后把它导入到其它系统中。其中使用最广泛的格式是Comma-Separated Values(CSV)。CSV文件是一个由若干条记录组成的普通文本文件。通常一行包括一条记录和一些定界符号,一般是逗号,用于分割记录的字段。我们将自定义管理站点,让它可以到处订单到CSV文件。

8.2.1 在管理站点你添加自定义操作

Django提供了大量自定义管理站点的选项。我们将修改对象列表视图,在其中包括一个自定义的管理操作。

一个管理操作是这样工作的:用户在管理站点的对象列表页面用复选框选择对象,然后选择一个在所有选中选项上执行的操作,最后执行操作。下图显示了操作位于管理站点的哪个位置:

第八章 管理支付和订单

创建自定义管理操作允许工作人员一次在多个元素上进行操作。

你可以编写一个常规函数来创建自定义操作,该函数需要接收以下参数:

  • 当前显示的ModelAdmin
  • 当前请求对象——一个HttpRequest实例
  • 一个用户选中对象的QuerySet

当在管理站点触发操作时,会执行这个函数。

我们将创建一个自定义管理操作,来下载一组订单的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]
    # 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. 我们创建了一个HttpResponse实例,其中包括定制的text/csv内容类型,告诉浏览器该响应看成一个CSV文件。我们还添加了Content-Disposition头部,表示HTTP响应包括一个附件。
  2. 我们创建了CSV的writer对象,用于向response对象中写入数据。
  3. 我们用模型的_meta选项的get_fields()方法动态获得模型的字段。我们派出了对多对和一对多关系。
  4. 我们用字段名写入标题行。
  5. 我们迭代给定的QuerySet,并为QuerySet返回的每个对象写入一行数据。因为CSV的输出值必须为字符串,所以我们格式化datetime对象。
  6. 我们设置函数的short_description属性,指定这个操作在模板中显示的名字。

我们创建了一个通用的管理操作,可以添加到所有ModelAdmin类上。

最后,如下添加export_to_csv管理操作到OrderAdmin类上:

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

在浏览器中打开http://127.0.0.1:8000/admin/orders/order/,管理操作如下图所示:

第八章 管理支付和订单

选中几条订单,然后在选择框中选择Export to CSV操作,接着点击Go按钮。你的浏览器会下载生成的order.csv文件。用文本编辑器打开下载的文件。你会看到以下格式的内容,其中包括标题行,以及你选择的每个Order对象行:

ID,first name,last name,email,address,postal code,city,created,updated,paid
1,allen,iverson,aaa@qq.com,北京市朝阳区,100012,北京市,11/05/2017,11/05/2017,False
2,allen,kobe,aaa@qq.com,北京市朝阳区,100012,北京市,11/05/2017,11/05/2017,False

正如你所看到的,创建管理操作非常简单。

8.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_activeis_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目录中创建以下目录结构:

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 extrastyle %}

{% block title %}
    Order {{ order.id }} {{ block.super }}
{% endblock title %}

{% 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 breadcrumbs %}

{% 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 content %}

这个模板用于在管理站点显示订单详情。模板扩展自Django管理站点的admin/base_site.html模板,其中包括主HTML结构和管理站的CSS样式。我们加载自定义的静态文件css/admin.css

为了使用静态文件,我们可以从本章的示例代码中获得它们。拷贝orders应用的static/目录中的静态文件,添加到你项目中的相同位置。

我们使用父模板中定义的块引入自己的内容。我们显示订单信息和购买的商品。

当你想要扩展一个管理模板时,你需要了解它的结构,并确定它存在哪些块。你可以在这里查看所有管理模板。

如果需要,你也可以覆盖一个管理模板。把要覆盖的模板拷贝到templates目录中,保留一样的相对路径和文件。Django的管理站点会使用你自定义的模板代替默认模板。

最后,让我们为管理站点的列表显示页中每个Order对象添加一个链接。编辑orders应用的amdin.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_order_detail的HTML链接。默认情况下,Django会转义HTML输出。我们必须设置函数的allow_tags属性为True,从而避免自动转义。

在任何Model方法,ModelAdmin方法,或者可调用函数中设置allow_tags属性为True可以避免HTML转义。使用allow_tags时,确保转义用户的输入,以避免跨站点脚本。

然后编辑OrderAdmin类来显示链接:

class OrderAdmin(admin.ModelAdmin):
    list_display = [... order_detail]

在浏览器中打开http://127.0.0.1:8000/admin/orders/order/,现在每行都包括一个View链接,如下图所示:

第八章 管理支付和订单

点击任何一个订单的View链接,会加载自定义的订单详情页面,如下图所示:

第八章 管理支付和订单

8.4 动态生成PDF单据

我们现在已经有了完成的结账和支付系统,可以为每个订单生成PDF单据了。有几个Python库可以生成PDF文件。一个流行的生成PDF文件的Python库是Reportlab。你可以在这里查看如果使用Reportlab输出PDF文件。

大部分情况下,你必须在PDF文件中添加自定义样式和格式。你会发现,让Python远离表现层,渲染一个HTML模板,然后把它转换为PDF文件更加方便。我们将采用这种方法,在Django中用模块生成PDF文件。我们会使用WeasyPrint,它是一个Python库,可以从HTML模板生成PDF文件。

8.4.1 安装WeasyPrint

首先,为你的操作系统安装WeasyPrint的依赖,请访问这里

然后用以下命令安装WeasyPrint:

pip install WeasyPrint

8.4.2 创建PDF模板

我们需要一个HTML文档作为WeasyPrint的输入。我们将创建一个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单据的模板。在这个模板中,我们显示所有订单详情和一个包括商品的HTML的<table>元素。我们还包括一个消息,显示订单是否支付。

8.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_{}.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变量中。然后,我们生成一个新的HttpResponse对象,指定application/pdf内容类型,并用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 manage.py collectstatic命令。你会看到这样结尾的输出:

You have requested to collect static files at the destination
location as specified in your settings:

    /Users/lakerszhy/Documents/GitHub/Django-By-Example/code/Chapter 8/myshop/static

This will overwrite existing files!
Are you sure you want to do this?

输入yes并按下Enter。你会看到一条消息,显示静态文件已经拷贝到STATIC_ROOT目录中。

collectstatic命令拷贝应用中所有静态文件到STATIC_ROOT设置中定义的目录。这样每个应用可以在static/目录中包括静态文件。你还可以在STATICFILES_DIRS设置中提供其它静态文件源。执行collectstatic命令时,STATICFILES_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模型的每条记录添加一个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属性中,如下所示:

class OrderAdmin(admin.ModelAdmin):
    list_display = [..., order_detail, order_pdf]

如果你为可调用对象指定了short_description属性,Django将把它作为列名。

在浏览器中打开http://127.0.0.1:8000/admin/orders/order。每行都会包括一个PDF链接,如下图所示:

第八章 管理支付和订单

点击任意一条订单的PDF链接。你会看到生成的PDF文件,下图是未支付的订单:

第八章 管理支付和订单

已支付订单如下图所示:

第八章 管理支付和订单

8.4.4 通过邮件发送PDF文件

当收到支付时,让我们给顾客发送一封包括PDF单据的邮件。编辑payment应用的signals.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

然后在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, 'aaa@qq.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类创建了一个邮件对象。然后把模板渲染到html变量中。我们从渲染的模板中生成PDF文件,并把它输出到一个BytesIO实例(内存中的字节缓存)中。接着我们用EmailMessage对象的attach()方法,把生成的PDF文件和out缓存中的内容添加到EmailMessage对象中。

记得在项目settings.py文件中设置发送邮件的SMTP设置,你可以参考第二章。

现在打开Ngrok提供的应用URL,完成一笔新的支付,就能在邮件中收到PDF单据了。

8.5 总结

在这一章中,你在项目中集成了支付网关。你自定义了Django管理站点,并学习了如果动态生成CSV和PDF文件。

下一章会深入了解Django项目的国际化和本地化。你还会创建一个优惠券系统和商品推荐引擎。