第八章 管理支付和订单

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 = 'mypaypalemail@myshop.com'
PAYPAL_TEST = True

这些设置分别是:

  • PAYPAL_RECEIVER_EMAIL:你PayPal账户的邮箱地址。用你创建PayPal账户的邮箱替换mypaypalemail@myshop.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,lakerszhy@gmail.com,北京市朝阳区,100012,北京市,11/05/2017,11/05/2017,False
2,allen,kobe,lakerszhy@gmail.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, '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类创建了一个邮件对象。然后把模板渲染到html变量中。我们从渲染的模板中生成PDF文件,并把它输出到一个BytesIO实例(内存中的字节缓存)中。接着我们用EmailMessage对象的attach()方法,把生成的PDF文件和out缓存中的内容添加到EmailMessage对象中。

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

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

8.5 总结

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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容