forked from minkolee/django2-by-example-ZH
-
Notifications
You must be signed in to change notification settings - Fork 0
/
chapter08.html
734 lines (687 loc) · 43.8 KB
/
chapter08.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="css/normalize.min.css">
<link rel="stylesheet" href="css/base.css">
<title>第八章 管理支付与订单</title>
</head>
<body>
<h1 id="top"><b>第八章 管理支付与订单</b></h1>
<p>上一章制作了一个带有商品品类展示和购物车功能的电商网站雏形,同时也学到了如何使用Celery给项目增加异步任务。本章将学习为网站集成支付网关以让用户通过信用卡付款,还将为管理后台扩展两项功能:将数据导出为CSV以及生成PDF发票。</p>
<p>本章的主要内容有:</p>
<ul>
<li>集成支付网关到项目中</li>
<li>将订单数据导出成CSV文件</li>
<li>为管理后台创建自定义视图</li>
<li>动态生成PDF发票</li>
</ul>
<h2 id="c8-1"><span class="title">1</span>集成支付网关</h2>
<p>支付网关是一种处理在线支付的网站或者程序,使用支付网关,就可以管理用户的订单,然后将支付过程交给一个可信赖且安全的第三方,而无需在我们自己的站点上处理支付信息。</p>
<p>支付网关有很多可供选择,我们将要集成的是叫做"Braintree"的支付网关。Braintree使用较为广泛,是Uber和Airbnb的支付服务提供商。Braintree提供了一套API用于支持信用卡,PayPal,Android Pay和Apple Pay等支付方式,官方网站在<a
href="https://www.braintreepayments.com/" target="_blank">https://www.braintreepayments.com/</a>。</p>
<p>Braintree提供了很多集成的方法,最简单的集成方式就是Drop-in集成,包含一个预先建立好的支付表单。但是为了自定义一些支付过程中的内容,这里选择使用高级的<em>Hosted Field</em>(字段托管)方式进行集成。在<a
href="https://developers.braintreepayments.com/guides/hosted-fields/overview/javascript/v3" target="_blank">https://developers.braintreepayments.com/guides/hosted-fields/overview/javascript/v3</a>可以看到详细的帮助文档。</p>
<p>支付表单中包含的信用卡号,CVV码,过期日期等信息必须要得到安全处理,Hosted Field集成方式将这些字段展示给用户的时候,在页面中渲染的是一个iframe框架。我们可以来自定义该字段的外观,但必须要遵循<a
href="https://en.wikipedia.org/wiki/Payment_Card_Industry_Data_Security_Standard" target="_blank">Payment Card Industry (PCI)安全支付</a>的要求。由于可以修改外观,用户并不会注意到页面使用了iframe。</p>
<p class="emp">译者注:原书在这里说的不是很清晰。Hosted Fields的意思是敏感字段由我们页面中的Braintree JavaScript客户端通过Braintree服务器生成并填入到页面中,而不是在模板中直接编写<code>input</code>字段。简单的说就是信用卡等敏感信息的字段是由Braintree托管生成,而不是我们自行编写。</p>
<h3 id="c8-1-1"><span class="title">1.1</span>注册Braintree沙盒测试账户</h3>
<p>需要注册一个Braintree账户。才能使用集成支付功能。我们先注册一个Braintree沙盒账户用于开发和测试。打开<a
href="https://www.braintreepayments.com/sandbox" target="_blank">https://www.braintreepayments.com/sandbox</a>,如下图所示:</p>
<p><img src="http://img.conyli.cc/django2/C08-01.jpg" alt=""></p>
<p>填写表单创建用户,之后会收到电子邮件验证,验证通过之后在<a href="https://sandbox.braintreegateway.com/login" target="_blank">https://sandbox.braintreegateway.com/login</a>进行登录。可以得到自己的商户ID和私有/公开密钥如下图所示:</p>
<p><img src="http://img.conyli.cc/django2/C08-02.jpg" alt=""></p>
<p>这些信息与使用Braintree API进行验证交易有关,注意保存好私钥,不要泄露给他人。</p>
<h3 id="c8-1-2"><span class="title">1.2</span>安装Braintree的Python模块</h3>
<p>Braintree为Python提供了一个模块操作其API,源代码地址在<a href="https://github.com/braintree/braintree_python" target="_blank">https://github.com/braintree/braintree_python</a>。我们将把这个<code>braintree</code>模块集成到站点中。</p>
<p>使用命令行安装<code>braintree</code>模块:</p>
<pre>
pip install braintree==3.45.0
</pre>
<p>之后在<code>settings.py</code>里配置:</p>
<pre>
# 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
)
</pre>
<p>将<code>BRAINTREE_MERCHANT_ID</code>,<code>BRAINTREE_PUBLIC_KEY</code>,<code>BRAINTREE_PRIVATE_KEY</code>的值替换成你自己的实际信息。</p>
<p class="hint">注意此处的设置 <code>Environment.Sandbox,</code>,表示我们当前集成的是沙盒环境。如果站点正式上线并且获取了正式的Braintree账户,必须修改成<code>Environment.Production</code>。Braintree对于正式账号会有新的商户ID和公钥/私钥。</p>
<p>Braintree的基础设置结束了,下一步是将支付网关和支付过程结合起来。</p>
<h3 id="c8-1-3"><span class="title">1.3</span>集成支付网关</h3>
<p>结账过程是这样的:</p>
<ol>
<li>将商品加入到购物车</li>
<li>从购物车中选择结账</li>
<li>输入信用卡信息并且支付</li>
</ol>
<p>针对支付功能,我们建立一个新的应用叫做<code>payment</code>:</p>
<pre>python manage.py startapp payment</pre>
<p>编辑<code>settings.py</code>文件,激活该应用:</p>
<pre>
INSTALLED_APPS = [
# ...
<b>'payment.apps.PaymentConfig',</b>
]
</pre>
<p><code>payment</code>现在已经被激活。</p>
<p>客户成功提交订单后,必须将该页面重定向到一个支付过程页面(目前是重定向到一个简单的成功页面)。编辑<code>orders</code>应用中的<code>views.py</code>,增加如下导入:</p>
<pre>
<b>from django.urls import reverse</b>
from django.shortcuts import render, <b>redirect</b>
</pre>
<p>在同一个文件内,将<code>order_create</code>视图的如下部分:</p>
<pre>
# 启动异步任务
order_created.delay(order.id)
return render(request, 'orders/order/created.html', locals())
</pre>
<p>替换成:</p>
<pre>
# 启动异步任务
order_created.delay(order.id)
<b># 在session中加入订单id
request.session['order_id'] = order.id
# 重定向到支付页面
return redirect(reverse('payment:process'))</b>
</pre>
<p>这样修改后,在成功创建订单之后,session中就保存了订单ID的变量<code>order_id</code>,然后用户被重定向至<code>payment:process</code> URL,这个URL稍后会编写。</p>
<p>注意必须为<code>order_created</code>视图启动Celery。</p>
<p>每次我们向Braintree中发送一个交易请求的时候,会生成一个唯一的交易ID号。因此我们在<code>Order</code>模型中增加一个字段用于存储这个交易ID号,这样可以将订单与Braintree交易联系起来。</p>
<p>编辑<code>orders</code>应用的<code>models.py</code>文件,为<code>Order</code>模型新增一行:</p>
<pre>
class Order(models.Model):
# ...
<b>braintree_id = models.CharField(max_length=150, blank=True)</b>
</pre>
<p>之后执行数据迁移程序,每一个订单都会保存与其关联的交易ID。目前准备工作都已经做完,剩下就是在支付过程中使用支付网关。</p>
<h4 id="c8-1-3-1"><span class="title">1.3.1</span>使用Hosted Fields进行支付</h4>
<p>Hosted Fields方式允许我们创建自定义的支付表单,使用自定义样式和表现形式。Braintree JavaScript SDK会在页面中动态的添加iframe框体用于展示Host Fields支付字段。当用户提交表单的时候,Hosted Fields会安全地提取用户的信用卡等信息,生成一个特征字符串(tokenize,令牌化)。如果令牌化过程成功,就可以使用这个特征字符串(token),通过视图中的<code>braintree</code>模块发起一个支付申请。</p>
<p>为此需要建立一个支付视图。这个视图的工作流程如下:</p>
<ol>
<li>用户提交订单时,视图通过<code>braintree</code>模块生成一个token,这个token用于Braintree JavaScript 客户端生成支付表单,并不是最终发送给支付网关的token。为了方便以下把这个token称为临时token,把最终提交给Braintree网站的token叫做交易token。</li>
<li>视图渲染支付表单所在的模板。页面中的Braintree JavaScript 客户端使用临时token来生成页面中的支付表单。</li>
<li>用户输入信用卡信息并且提交支付表单后,Braintree JavaScript 客户端会生成交易token,将这个交易token通过<code>POST</code>请求发送到视图</li>
<li>视图获取交易token之后,通过<code>braintree</code>模块向网站提交交易请求。</li>
</ol>
<p>了解了工作流程之后,来编写相关视图,编辑<code>payment</code>应用中的<code>views.py</code>文件,添加下列代码:</p>
<pre>
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})
</pre>
<p>这个<code>payment_process</code>视图管理支付过程,工作流程如下 :</p>
<ol>
<li>从session中取出由<code>order_create</code>视图设置的<code>order_id</code>变量。</li>
<li>获取<code>Order</code>对象,如果没找到,返回<code>404 Not Found</code>错误</li>
<li>如果接收到<code>POST</code>请求,获取交易token <code>payment_method_nonce</code>,使用交易token和<code>braintree.Transaction.sale()</code>方法生成新的交易,该方法的几个参数解释如下:<ol>
<li><code>amount</code>:总收款金额</li>
<li><code>payment_method_nonce</code>:交易token,由页面中的Braintree JavaScript 客户端生成。</li>
<li><code>options</code>:其他选项,<code>submit_for_settlement</code>设置为<code>True</code>表示生成交易信息完毕的时候就立刻提交。</li>
</ol></li>
<li>如果交易成功,通过设置<code>paid</code>属性为<code>True</code>,将订单标记为已支付,将交易ID存储到<code>braintree_id</code>属性中,之后重定向至<code>payment:done</code>,如果交易失败就重定向至<code>payment:canceled</code>。</li>
<li>如果视图接收到<code>GET</code>请求,生成临时token交给页面中的Braintree JavaScript 客户端。</li>
</ol>
<p>下边建立支付成功和失败时的处理视图,在<code>payment</code>应用的views.py中添加下列代码:</p>
<pre>
def payment_done(request):
return render(request, 'payment/done.html')
def payment_canceled(request):
return render(request, 'payment/canceled.html')
</pre>
<p>然后在<code>payment</code>目录下建立<code>urls.py</code>,为上述视图配置路由:</p>
<pre>
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'),
]
</pre>
<p>这是支付流程的路由,配置了如下URL模式:</p>
<ul>
<li><code>process</code>:处理支付的视图</li>
<li><code>done</code>:支付成功的视图</li>
<li><code>canceled</code>:支付未成功的视图</li>
</ul>
<p>编辑<code>myshop</code>项目的根<code>urls.py</code>文件,为<code>payment</code>应用配置二级路由:</p>
<pre>
urlpatterns = [
# ...
<b>path('payment/', include('payment.urls', namespace='payment')),</b>
path('', include('shop.urls', namespace='shop')),
]
</pre>
<p>依然要注意这一行要放到<code>shop.urls</code>上边,否则无法被解析到。</p>
<p>之后是建立视图,在payment目录下建立templates/payment/目录,并在其中建立 process.html, done.html,canceled.html三个模板。先来编写process.html:</p>
<p>在<code>payment</code>应用内建立下列目录和文件结构:</p>
<pre>
templates/
payment/
process.html
done.html
canceled.html
</pre>
<p>编辑<code>payment/process.html</code>,添加下列代码:</p>
<pre>
{% 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 %}
</pre>
<p>这是用户填写信用卡信息并且提交支付的模板,我们用<code><div></code>替代<code><input></code>使用在信用卡号,CVV码和过期日期字段上。这些字段就是Braintree JavaScript客户端渲染的iframe字段。还使用了一个名称为<code>payment_method_nonce</code>的<code><input></code>元素用于提交交易ID到后端。</p>
<p>在模板中还导入了Braintree JavaScript SDK的<code>client.min.js</code>和Hosted Fields组件<code>hosted-fields.min.js</code>,然后执行了下列JS代码:</p>
<ol>
<li>使用<code>braintree.client.create()</code>方法,传入<code>client_token</code>即<code>payment_process</code>视图里生成的临时token,实例化Braintree JavaScript 客户端。</li>
<li>使用<code>braintree.hostedFields.create()</code>实例化Hosted Field组件</li>
<li>给<code>input</code>字段应用自定义样式</li>
<li>给<code>cardnumber</code>,<code>cvv</code>, 和<code>expiration-date</code>字段设置<code>id</code>选择器</li>
<li>给表单的<code>submit</code>行为绑定一个事件,当表单被点击提交时,Braintree SDK 使用表单中的信息,生成交易token放入<code>payment_method_nonce</code>字段中,然后提交表单。</li>
</ol>
<p>编辑<code>payment/done.html</code>文件,添加下列代码:</p>
<pre>
{% extends "shop/base.html" %}
{% block content %}
<h1>Your payment was successful</h1>
<p>Your payment has been processed successfully.</p>
{% endblock %}
</pre>
<p>这是订单成功支付时用户被重定向的页面。</p>
<p>编辑<code>canceled.html</code>,添加下列代码:</p>
<pre>
{% extends "shop/base.html" %}
{% block content %}
<h1>Your payment has not been processed</h1>
<p>There was a problem processing your payment.</p>
{% endblock %}
</pre>
<p>这是订单未支付成功时用户被重定向的页面。之后我们来试验一下付款。</p>
<h3 id="c8-1-4"><span class="title">1.4</span>测试支付</h3>
<p>打开系统命令行窗口然后运行RabbitMQ:</p>
<pre>rabbitmq-server</pre>
<p>再启动一个命令行窗口,启动Celery worker:</p>
<pre>celery -A myshop worker -l info</pre>
<p>再启动一个命令行窗口,启动站点:</p>
<pre>python manage.py runserver</pre>
<p>之后在浏览器中打开<a href="http://127.0.0.1:8000/" target="_blank">http://127.0.0.1:8000/</a>加入一些商品到购物车,提交订单,当按下PLACE ORDER按钮后,订单信息被保存进数据库,订单ID被附加到session上,然后进入支付页面。</p>
<p>支付页面从session中取得订单id然后在iframe中渲染Hosted Fields,像下图所示:</p>
<p><img src="http://img.conyli.cc/django2/C08-03.jpg" alt=""></p>
<p>可以看一下页面的HTML代码,从而理解什么是Hosted Fields。</p>
<p>针对沙盒测试环境,Braintree提供了一些测试用的信用卡资料,可以进行付款成功或失败的测试,可以在<a
href="https://developers.braintreepayments.com/guides/credit-cards/testing-go-live/python" target="_blank">https://developers.braintreepayments.com/guides/credit-cards/testing-go-live/python</a>找到,我们来使用<code>4111 1111 1111 1111</code>这个信用卡号,在CVV码中填入<code>123</code>,到期日期填入未来的某一天比如<code>12/20</code>:</p>
<p><img src="http://img.conyli.cc/django2/C08-04.jpg" alt=""></p>
<p>之后点击Pay,应该可以看到成功页面:</p>
<p><img src="http://img.conyli.cc/django2/C08-05.jpg" alt=""></p>
<p>说明付款已经成功。可以在<a href="https://sandbox.braintreegateway.com/login" target="_blank">https://sandbox.braintreegateway.com/login</a>登录,然后在左侧菜单选Transaction里搜索最近的交易,可以看到如下信息:</p>
<p><img src="http://img.conyli.cc/django2/C08-i01.jpg" alt=""></p>
<p class="emp">译者注:Braintree网站在成书后有部分改版,读者看到的支付详情页面可能与上述图片有一些区别。</p>
<p>然后再查看管理站点<a href="http://127.0.0.1:8000/admin/orders/order/" target="_blank">http://127.0.0.1:8000/admin/orders/order/</a>中的对应记录,该订单应该已经被标记为已支付,而且记录了交易ID,如下图所示:</p>
<p><img src="http://img.conyli.cc/django2/C08-07.jpg" alt=""></p>
<p>我们现在就成功集成了支付功能。</p>
<h3 id="c8-1-5"><span class="title">1.5</span>正式上线</h3>
<p>在沙盒环境中测试通过之后,需要正式上线的话,需要到<a href="https://www.braintreepayments.com" target="_blank">https://www.braintreepayments.com</a>创建正式账户。</p>
<p>在部署到生产环境时,需要将<code>settings.py</code>中的商户ID和公钥私钥更新为正式账户的对应信息,然后将其中的<code>Environment.Sandbox</code>修改为<code>Environment.Production</code>。正式上线的具体步骤可以参考:<a href="https://developers.braintreepayments.com/start/go-live/python" target="_blank">https://developers.braintreepayments.com/start/go-live/python</a>。</p>
<h2 id="c8-2"><span class="title">2</span>导出订单为CSV文件</h2>
<p>有时我们想将某个模型中的数据导出为一个文件,用于在其他系统导入。常用的一种数据交换格式是CSV(逗号分隔数据)文件。CSV文件是一个纯文本文件,包含很多条记录。通常一行是一条记录,用特定的分隔符(一般是逗号)分隔每个字段的值。我们准备自定义管理后台,增加导出CSV文件的功能。</p>
<h3 id="c8-2-1"><span class="title">2.1</span>给管理后台增加自定义管理行为(actions)</h3>
<p>Django允许对管理后台的很多内容进行自定义修改。我们准备在列出具体数据的视图内增加导出CSV文件的功能。</p>
<p>一个管理行为是指如下操作:用户从管理后台列出某个模型中具体记录的页面内,使用复选框选中要操作的记录,然后从下拉菜单中选择一项操作,之后就会针对所有选中的记录执行操作。这个action的位置如下图所示:</p>
<p><img src="http://img.conyli.cc/django2/C08-08.jpg" alt=""></p>
<p class="hint">创建自定义管理行为可以让管理员批量对记录进行操作。</p>
<p>可以通过写一个符合要求的自定义函数作为一项管理行为,这个函数要接受如下参数:</p>
<ul>
<li>当前显示的<code>ModelAdmin</code>类</li>
<li>当前的request对象,是一个<code>HttpResponse</code>实例</li>
<li>用户选中的内容组成的QuerySet</li>
</ul>
<p>在选中一个action选项然后点击旁边的Go按钮的时候,该函数就会被执行。</p>
<p>我们就准备在下拉action清单里增加一项导出CSV数据的功能,为此先来修改<code>orders</code>应用中的<code>admin.py</code>韦健,将下列代码加在<code>OrderAdmin</code>类定义之前:</p>
<pre>
import csv
import datetime
from django.http import HttpResponse
def exprot_to_csv(modeladmin, request, queryset):
opts = modeladmin.model._meta
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename={}.csv'.format(opts.verbose_name)
writer = csv.writer(response)
fields = [field for field in opts.get_fields() if not field.many_to_many and not field.one_to_many]
writer.writerow(field.verbose_name for field in fields)
for obj in queryset:
data_row = []
for field in fields:
value = getattr(obj, field.name)
if isinstance(value, datetime.datetime):
value = value.strftime('%d/%m/%Y')
data_row.append(value)
writer.writerow(data_row)
return response
exprot_to_csv.short_description = 'Export to CSV'
</pre>
<p>在这个函数里我们做了如下事情:</p>
<ol>
<li>创建一个<code>HttpResponse</code>对象,将其内容类型设置为<code>text/csv</code>,以告诉浏览器将其视为一个CSV文件。还为请求头附加了<code>Content-Disposition</code>头部信息,告诉浏览器这个请求带有一个附加文件。</li>
<li>创建一个CSV的<code>writer</code>对象,用于向Http响应对象<code>response</code>中写入CSV文件数据。</li>
<li>通过<code>_meta</code>的<code>get_fields()</code>方法获取所有字段名,动态获取<code>model</code>的字段,排除了所有一对多和多对多字段。</li>
<li>将字段名写入到响应的CSV数据中,作为第一行数据,即表头</li>
<li>迭代QuerySet,将其中每一个对象的数据写入一行中,注意特别对<code>datetime</code>采用了格式化功能,以转换成字符串。</li>
<li>最后设置了该函数对象的<code>short_description</code>属性,该属性的值为在action列表中显示的功能名称。</li>
</ol>
<p>这样我们就创建了一个通用的管理功能,可以操作任何<code>ModelAdmin</code>对象。</p>
<p>之后在<code>OrderAdmin</code>类中增加这个新的<code>export_to_csv</code>功能:</p>
<pre>
class OrderAdmin(admin.ModelAdmin):
WeasyPrint # ...
<b>actions = [export_to_csv]</b>
</pre>
<p>在浏览器中打开<a href="http://127.0.0.1:8000/admin/orders/order/" target="_blank">http://127.0.0.1:8000/admin/orders/order/</a>查看订单类,页面如下:</p>
<p><img src="http://img.conyli.cc/django2/C08-09.jpg" alt=""></p>
<p>选择一些订单,然后选择上边的Export to CSV功能,然后点击Go按钮,浏览器就会下载一个<code>order.csv</code>文件。</p>
<p class="emp">译者注:此处下载的文件名可能不是<code>order.csv</code>,这是因为原书没有在<code>orders</code>应用的<code>models.py</code>中为<code>Order</code>类的<code>meta</code>类增加<code>verbose_name</code>属性,手工增加<code>verboser_name</code>的值为<code>order</code>,这样才能下载到和原书里写的名称一样的<code>order.csv</code>文件。</p>
<p>使用文本编辑器打开刚下载的CSV文件,可以看到里边的内容类似:</p>
<pre>
ID,first name,last name,email,address,postal
code,city,created,updated,paid,braintree id
3,Antonio,Melé,[email protected],Bank Street,WS
J11,London,25/02/2018,25/02/2018,True,2bwkx5b6
</pre>
<p>可以看到,实现管理功能的方法很直接。Django中将数据输出为CSV的说明可以参考<a href="https://docs.djangoproject.com/en/2.0/howto/outputting-csv/" target="_blank">https://docs.djangoproject.com/en/2.0/howto/outputting-csv/</a>。</p>
<h2 id="c8-3"><span class="title">3</span>用自定义视图扩展管理后台的功能</h2>
<p>不仅仅是配置<code>ModelAdmin</code>,创建管理行为和覆盖内置模板,有时候可能需要对管理后台进行更多的自定义。这时你需要创建自定义的管理视图。使用管理视图,就可以实现自己想要的功能,要注意的只是自定义管理视图应该只允许管理员进行操作,同时继承内置模板以保持风格一致性。</p>
<p>我们这次来修改一下管理后台,增加一个自定义的功能用于显示一个订单的信息。修改<code>orders</code>应用中的<code>views.py</code>文件,增加如下内容:</p>
<pre>
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})
</pre>
<p><code>@staff_member_required</code>装饰器只允许<code>is_staff</code>和<code>is_active</code>字段同时为<code>True</code>的用户才能使用被装饰的视图。在这个视图中,通过传入的id取得对应的<code>Order</code>对象。</p>
<p>然后配置<code>orders</code>应用的<code>urls.py</code>文件,增加一条路由:</p>
<pre>
path('admin/order/<int:order_id>/', views.admin_order_detail, name='admin_order_detail')
</pre>
<p>然后在order应用的templates/目录下创建如下文件目录结构:</p>
<pre>
admin/
orders/
order/
detail.html
</pre>
<p>编辑这个<code>detail.html</code>,添加下列代码:</p>
<pre>
{% 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 %}
</pre>
<p>这个模板用于在管理后台显示订单详情。模板继承了<code>admin/base_site.html</code>母版,这个母版包含Django管理站点的基础结构和CSS类,然后还加载了自定义的样式表<code>css/admin.css</code>。</p>
<p>自定义CSS样式表在随书代码中,像之前的项目一样将其复制到对应目录。</p>
<p>我们使用的块名称都定义在母版中,在其中编写了展示订单详情的部分。</p>
<p>当你需要继承Django的内置模板时,必须了解内置模板的结构,在<a
href="https://github.com/django/django/tree/2.1/django/contrib/admin/templates/admin" target="_blank">https://github.com/django/django/tree/2.1/django/contrib/admin/templates/admin</a>可以找到内置模板的信息。</p>
<p>如果需要覆盖内置模板,需要将自己编写的模板命名为与原来模板相同,然后复制到<code>templates</code>下,设置与内置模板相同的相对路径和名称。管理后台就会优先使用当前项目下的模板。</p>
<p>最后,还需要再管理后台中为每个<code>Order</code>对象增加一个链接到我们自行编写的视图,编辑<code>orders</code>应用的<code>admin.py</code>文件,在<code>OrderAdmin</code>类之前增加如下代码:</p>
<pre>
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])))
</pre>
<p>这个函数接受一个Order对象作为参数,返回一个解析后的<code>admin_order_detail</code>名称对应的URL,由于Django默认会将HTML代码转义,所以加上<code>mark_safe</code>。</p>
<p class="hint">使用mark_safe可以不让HTML代码转义。使用mark_safe的时候,确保对于用户的输入依然要进行转义,以防止跨站脚本攻击。</p>
<p>然后编辑<code>OrderAdmin</code>类来显示链接:</p>
<pre>
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ['id', 'first_name', 'last_name', 'email', 'address', 'postal_code', 'city', 'paid', 'created',
'updated', <b>order_detail</b>]
</pre>
<p>然后启动站点,访问<a href="http://127.0.0.1:8000/admin/orders/order/" target="_blank">http://127.0.0.1:8000/admin/orders/order/</a>,可以看到新增了一列:</p>
<p><img src="http://img.conyli.cc/django2/C08-10.jpg" alt=""></p>
<p>点击任意一个订单的View链接查看详情,会进入Django管理后台风格的订单详情页:</p>
<p><img src="http://img.conyli.cc/django2/C08-11.jpg" alt=""></p>
<h2 id="c8-4"><span class="title">4</span>动态生成PDF发票</h2>
<p>我们现在已经实现了完整的结账和支付功能,可以为每个订单生成一个PDF发票。有很多Python库都可以用来生成PDF,常用的是Reportlab库,该库也是django 2.0官方推荐使用的库,可以在<a href="https://docs.djangoproject.com/en/2.0/howto/outputting-pdf/" target="_blank">https://docs.djangoproject.com/en/2.0/howto/outputting-pdf/</a>查看详情。</p>
<p>大部分情况下,PDF文件中都要包含一些样式和格式,这个时候通过渲染后的HTML模板生成PDF更加方便。我们在Django中集成一个模块来按照上述方法转换PDF。这里我们使用WeasyPrint库,这个库用来从HTML模板生成PDF文件。</p>
<h3 id="c8-4-1"><span class="title">4.1</span>安装WeasyPrint</h3>
<p>需要先按照<a href="http://weasyprint.org/docs/install/#platforms" target="_blank">http://weasyprint.org/docs/install/#platforms</a>的指引安装相关依赖,之后通过<code>pip</code>安装WeasyPrint:</p>
<pre>pip install WeasyPrint==0.42.3</pre>
<p class="emp">译者注:WeasyPrint在Windows下的配置非常麻烦,还未必能够成功,推荐Windows用户使用Linux虚拟机。</p>
<h3 id="c8-4-2"><span class="title">4.2</span>创建PDF模板</h3>
<p>需要创建一个模板作为输入给WeasyPrint的数据。我们创建一个带有订单内容和CSS样式的模板,通过Django渲染,将最终生成的页面传给WeasyPrint。</p>
<p>在<code>orders</code>应用的<code>templates/orders/order/</code>目录下创建<code>pdf.html</code>文件,添加下列代码:</p>
<pre>
<html>
<body>
<h1>My Shop</h1>
<p>
Invoice no. {{ order.id }}<span style="color:red"><br></span>
<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>
</pre>
<p class="emp">译者注:注意第五行标红的部分,原书错误的写成了<code></br></code>。</p>
<p>这个模板的内容很简单,使用一个<code><table></code>元素展示订单的用户信息和商品信息,还添加了消息显示订单是否已支付。</p>
<h3 id="c8-4-3"><span class="title">4.3</span>创建渲染PDF的视图</h3>
<p>我们来创建在管理后台内生成订单PDF文件的视图,在<code>orders</code>应用的<code>views.py</code>文件内增加下列代码:</p>
<pre>
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
</pre>
<p>这是生成PDF文件的视图,使用了<code>@staff_member_required</code>装饰器使该视图只能由管理员访问。通过ID获取Order对象,然后使用<code>render_to_string()</code>方法渲染<code>orders/order/pdf.html</code>,渲染后的模板以字符串形式保存在<code>html</code>变量中。然后创建一个新的<code>HttpResponse</code>对象,并为其附加<code>application/pdf</code>和<code>Content-Disposition</code>请求头信息。使用WeasyPrint从字符串形式的HTML中转换PDF文件并写入<code>HttpResponse</code>对象。这个生成的PDF会带有<code>STATIC_ROOT</code>路径下的<code>css/pdf.css</code>中的样式,最后返回响应。</p>
<p>如果发现文件缺少CSS样式,记得把CSS文件从随书目录中放入<code>shop</code>应用的<code>static/</code>目录下。</p>
<p>我们这里还没有配置<code>STATIC_ROOT</code>变量,这个变量规定了项目的静态文件存放的路径。编辑<code>myshop</code>项目的<code>settings.py</code>文件,添加下面这行:</p>
<pre>
STATIC_ROOT = os.path.join(BASE_DIR, 'static/')
</pre>
<p>然后运行如下命令:</p>
<pre>
python manage.py collectstatic
</pre>
<p>之后会看到:</p>
<pre>
120 static files copied to 'code/myshop/static'.
</pre>
<p>这个命令会把所有已经激活的应用下的<code>static/</code>目录中的文件,复制到<code>STATIC_ROOT</code>指定的目录中。每个应用都可以有自己的static/目录存放静态文件,还可以在<code>STATICFILES_DIRS</code>中指定其他的静态文件路径,当执行<code>collectstatic</code>时,会把所有<code>STATICFILES_DIRS</code>目录内的文件都复制过来。如果再次执行<code>collectstatic</code>,会提示是否需要覆盖已经存在的静态文件。</p>
<p class="emp">译者注:虽然将静态文件分开存放在每个应用的<code>static/</code>下可以正常运行开发中的站点,但在正式上线的最好统一静态文件的存放地址,以方便配置Web服务程序。</p>
<p>编辑<code>orders</code>应用的<code>urls.py</code>文件,为视图配置URL:</p>
<pre>
urlpatterns = [
# ...
<b>path('admin/order/<int:order_id</pdf/',views.admin_order_pdf,name='admin_order_pdf'),</b>
]
</pre>
<p>像导出CSV一样,我们要在管理后台的展示页面中增加一个链接到这个视图的URL。打开<code>orders</code>应用的<code>admin.py</code>文件,在<code>OrderAdmin</code>类之前增加:</p>
<pre>
def order_pdf(obj):
return mark_safe('<a href="{}">View</a>'.format(reverse('orders:admin_order_pdf', args=[obj.id])))
order_pdf.short_description = 'Invoice'
</pre>
<p>如果指定了<code>short_description</code>属性,Django就会用该属性的值作为列名。</p>
<p>为<code>OrderAdmin</code>的<code>list_display</code>增加这个新的字段<code>order_pdf</code>:</p>
<pre>
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ['id', 'first_name', 'last_name', 'email', 'address', 'postal_code', 'city', 'paid', 'created',
'updated', order_detail, <b>order_pdf</b>]
</pre>
<p>在浏览器中打开<a href="http://127.0.0.1:8000/admin/orders/order/" target="_blank">http://127.0.0.1:8000/admin/orders/order/</a>,可以看到新增了一列字段用于转换PDF:</p>
<p><img src="http://img.conyli.cc/django2/C08-12.jpg" alt=""></p>
<p>点击PDF链接,浏览器应该会下载一个PDF文件,如果是尚未支付的订单,样式如下:</p>
<p><img src="http://img.conyli.cc/django2/C08-13.jpg" alt=""></p>
<p>已经支付的订单,则类似这样:</p>
<p><img src="http://img.conyli.cc/django2/C08-14.jpg" alt=""></p>
<h3 id="c8-4-4"><span class="title">4.4</span>使用电子邮件发送PDF文件</h3>
<p>当支付成功的时,我们发送带有PDF发票的邮件给用户。编辑<code>payment</code>应用中的<code>views.py</code>视图,添加如下导入语句:</p>
<pre>
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
</pre>
<p>在<code>payment_process</code>视图中,<code>order.save()</code>这行之后,以相同的缩进添加下列代码:</p>
<pre>
def payment_process(request):
# ......
if request.method == "POST":
# ......
if result.is_success:
# ......
order.save()
<b># 创建带有PDF发票的邮件</b>
<b>subject = 'My Shop - Invoice no. {}'.format(order.id)</b>
<b>message = 'Please, find attached the invoice for your recent purchase.'</b>
<b>email = EmailMessage(subject, message, '[email protected]', [order.email])</b>
<b># 生成PDF文件</b>
<b>html = render_to_string('orders/order/pdf.html', {'order': order})</b>
<b>out = BytesIO()</b>
<b>stylesheets = [weasyprint.CSS(settings.STATIC_ROOT + 'css/pdf.css')]</b>
<b>weasyprint.HTML(string=html).write_pdf(out, stylesheets=stylesheets)</b>
<b># 附加PDF文件作为邮件附件</b>
<b>email.attach('order_{}.pdf'.format(order.id), out.getvalue(), 'application/pdf')</b>
<b># 发送邮件</b>
<b>email.send()</b>
return redirect('payment:done')
else:
return redirect('payment:canceled')
else:
# ......
</pre>
<p>这里使用了<code>EmailMessage</code>类创建一个邮件对象<code>email</code>,然后将模板渲染到<code>html</code>变量中,然后通过WeasyPrint将其写入一个<code>BytesIO</code>二进制字节对象,之后使用<code>attach</code>方法,将这个字节对象的内容设置为<code>EmailMessage</code>的附件,同时设置文件类型为PDF,最后发送邮件。</p>
<p>记得在<code>settings.py</code>中设置SMTP服务器,可以参考第二章。</p>
<p>现在,可以尝试完成一个新的付款,并且在邮箱内接收PDF发票。</p>
<h1><b>总结</b></h1>
<p>在这一章中,我们集成了支付网关,自定义了Django管理后台,还学习了如何将数据以CSV文件格式导出和动态生成PDF文件。</p>
<p>下一章我们将深入了解Django项目的国际化和本地化设置,还将创建一个优惠码功能和商品推荐引擎。</p>
</body>
</html>