├── README.md ├── ch01 └── 1-python-web-dev-intro.ipynb ├── ch02 └── 2-flask-intro.ipynb ├── ch03 └── 3-jinja2-templates.ipynb ├── ch04 └── 4-web-forms.ipynb ├── ch05 └── 5-peewee-orm.ipynb ├── ch06 └── 6-cli-interface.ipynb ├── ch07 └── 7-large-app-structure.ipynb ├── ch08 └── 8-user-authentication.ipynb ├── ch09 └── 9-user-roles.ipynb ├── ch10 └── 10-user-profiles.ipynb ├── ch11 └── 11-blog-posts.ipynb ├── ch12 └── 12-followers.ipynb ├── ch13 └── 13-user-comments.ipynb ├── ch14 └── 14-web-service-and-api.ipynb ├── ch15 └── 15-testing.ipynb ├── images ├── flask-logo.png ├── http_client-server.png └── python-web-simple-architecture.png └── materials └── syllabus.md /README.md: -------------------------------------------------------------------------------- 1 | # Python Web开发 2 | 3 | * [教学大纲](./materials/syllabus.md) 4 | * [课程内容](#org775fged) 5 | 6 | 7 | 8 | ## 课程内容 9 | 10 | | 章节 | 名称 | 11 | |--------|-------------------------------------------------------------| 12 | | 第1章 | [Python Web开发初识](./ch01/1-python-web-dev-intro.ipynb) | 13 | | 第2章 | [Flask框架简介](./ch02/2-flask-intro.ipynb) | 14 | | 第3章 | [模板](./ch03/3-jinja2-templates.ipynb) | 15 | | 第4章 | [Web表单处理](./ch04/4-web-forms.ipynb) | 16 | | 第5章 | [使用ORM操作数据库](./ch05/5-peewee-orm.ipynb) | 17 | | 第6章 | [Flask命令行接口](./ch06/6-cli-interface.ipynb) | 18 | | 第7章 | [Flask大型应用程序结构](./ch07/7-large-app-structure.ipynb) | 19 | | 第8章 | [用户认证](./ch08/8-user-authentication.ipynb) | 20 | | 第9章 | [用户角色](./ch09/9-user-roles.ipynb) | 21 | | 第10章 | [用户资料](./ch10/10-user-profiles.ipynb) | 22 | | 第11章 | [博客文章](./ch11/11-blog-posts.ipynb) | 23 | | 第12章 | [关注用户](./ch12/12-followers.ipynb) | 24 | | 第13章 | [文章评论](./ch13/13-user-comments.ipynb) | 25 | | 第14章 | [Web服务和API](./ch14/14-web-service-and-api.ipynb) | 26 | | 第15章 | [Flask应用测试](./ch15/15-testing.ipynb) | 27 | -------------------------------------------------------------------------------- /ch01/1-python-web-dev-intro.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": { 6 | "ein.tags": "worksheet-0", 7 | "slideshow": { 8 | "slide_type": "-" 9 | } 10 | }, 11 | "source": [ 12 | "\n", 13 | "# Python Web开发初识\n", 14 | "\n", 15 | "\n", 16 | "\n", 17 | "\n", 18 | "## HTTP client-server\n", 19 | "\n", 20 | "![img](../images/http_client-server.png \"HTTP Client-Server\")\n", 21 | "\n", 22 | "- 通过请求和响应的交换达成通信\n", 23 | "- 不保存通信状态(stateless)\n", 24 | "- 使用 **URI** 定位互联网上的资源\n", 25 | "- 请求资源时使用方法下达命令(GET、POST、HEAD等)\n", 26 | "- 通过持久连接节省通信量\n", 27 | "- 使用cookie来进行状态管理\n", 28 | "\n", 29 | "\n", 30 | "\n", 31 | "\n", 32 | "### HTTP请求\n", 33 | "\n", 34 | " GET / HTTP/1.1\n", 35 | " Connection: close\n", 36 | " Host: httpbin.org\n", 37 | " User-agent: HTTPie/0.9.9\n", 38 | " Accept: */*\n", 39 | " Accept-Encoding: gzip, deflate\n", 40 | " Accept-Language: en\n", 41 | " Accept-Charset: *, utf-8\n", 42 | "\n", 43 | " Optional data\n", 44 | " ...\n", 45 | "\n", 46 | "- 第一行定义 **请求类型** 、 **文档(选择符)** 和 **协议版本**\n", 47 | "- 接着是报头行,包括各种有关客户端的信息\n", 48 | "- 报头行后面是一个空白行,表示报头行结束\n", 49 | "- 之后是发送表单的信息或者上传文件的事件中可能出现的数据\n", 50 | "- 报头的每一行都应该使用回车符或者换行符('\\r\\n')终止\n", 51 | "\n", 52 | "下表是常见HTTP请求方法:\n", 53 | "\n", 54 | "| 方法 | 描述 |\n", 55 | "|------|-----------|\n", 56 | "| GET | 获取文档 |\n", 57 | "| POST | 将数据发布到表单 |\n", 58 | "| HEAD | 仅返回报头信息 |\n", 59 | "| PUT | 将数据上传到服务器 |\n", 60 | "| … | … |\n", 61 | "\n", 62 | "\n", 63 | "\n", 64 | "\n", 65 | "### HTTP响应\n", 66 | "\n", 67 | " HTTP/1.1 200 OK\n", 68 | " Connection: keep-alive\n", 69 | " Content-Length: 580\n", 70 | " Content-Type: application/json\n", 71 | " Date: Tue, 25 Apr 2017 04:28:37 GMT\n", 72 | " Server: gunicorn/19.7.1\n", 73 | " ...\n", 74 | " Header: data\n", 75 | "\n", 76 | " Data\n", 77 | " ...\n", 78 | "\n", 79 | "- 第一行表示 **HTTP协议版本** 、 **成功代码** 和 **返回消息**\n", 80 | "- 响应行之后是一系列报头字段, 包含返回文档的类型、文档大小、Web服务器软件、cookie等方面的信息\n", 81 | "- 通过空白行结束报头\n", 82 | "- 之后是所请求文档的原始数据\n", 83 | "\n", 84 | "下表是HTTP常见状态码:\n", 85 | "\n", 86 | "| 代码 | 描述 | 符号常量 |\n", 87 | "|------------|---------|-------------------------|\n", 88 | "| 成功代码(2xx) | | |\n", 89 | "| 200 | 成功 | OK |\n", 90 | "| 201 | 创建 | CREATED |\n", 91 | "| 202 | 接受 | ACCEPTED |\n", 92 | "| 204 | 无内容 | NO\\_CONTENT |\n", 93 | "| 重定向(3xx) | | |\n", 94 | "| 300 | 多种选择 | MULTIPLE\\_CHOICES |\n", 95 | "| 301 | 永久移动 | MOVED\\_PERMANENTLY |\n", 96 | "| 302 | 可被303替代 | FOUND |\n", 97 | "| 303 | 临时移动 | SEE\\_OTHER |\n", 98 | "| 304 | 不修改 | NOT\\_MODIFIED |\n", 99 | "| 客户端错误(4xx) | | |\n", 100 | "| 400 | 请求错误 | BAD\\_REQUEST |\n", 101 | "| 401 | 未授权 | UNAUTHORIZED |\n", 102 | "| 403 | 禁止访问 | FORBIDDEN |\n", 103 | "| 404 | 未找到 | NOT\\_FOUND |\n", 104 | "| 405 | 方法不允许 | METHOD\\_NOT\\_ALLOWED |\n", 105 | "| 服务器错误(5xx) | | |\n", 106 | "| 500 | 内部服务器错误 | INTERNAL\\_SERVER\\_ERROR |\n", 107 | "| 501 | 未实现 | NOT\\_IMPLEMENTED |\n", 108 | "| 502 | 网关错误 | BAD\\_GATEWAY |\n", 109 | "| 503 | 服务不可用 | SERVICE\\_UNAVAILABLE |\n", 110 | "\n", 111 | "\n", 112 | "\n", 113 | "\n", 114 | "## Python3 标准Web库\n", 115 | "\n", 116 | "- http处理所有客户端—服务器HTTP请求的具体细节\n", 117 | " - client处理客户端部分\n", 118 | " - server提供了实现HTTP服务器的各种类\n", 119 | " - cookies支持在服务器端处理HTTP cookie\n", 120 | " - cookiejar支持在客户端存储和管理HTTP cookie\n", 121 | "\n", 122 | "- urllib基于http的高层库,用于编写与HTTP服务器等交互的客户端\n", 123 | " - request处理客户端请求\n", 124 | " - response处理服务器端响应\n", 125 | " - parse用于操作URL字符串\n" 126 | ] 127 | }, 128 | { 129 | "cell_type": "code", 130 | "execution_count": null, 131 | "metadata": { 132 | "autoscroll": false, 133 | "collapsed": false, 134 | "ein.tags": "worksheet-0", 135 | "slideshow": { 136 | "slide_type": "-" 137 | } 138 | }, 139 | "outputs": [], 140 | "source": [ 141 | "import urllib.request as ur\n", 142 | "url = 'http://httpbin.org/'\n", 143 | "conn = ur.urlopen(url)\n", 144 | "print(conn)\n", 145 | "print('=' * 50)\n", 146 | "data = conn.read()\n", 147 | "print(data[:16])\n", 148 | "print(conn.status)\n", 149 | "\n", 150 | "print(conn.getheader('Content-Type'))\n", 151 | "for key, value in conn.getheaders():\n", 152 | " print(key, value, sep=': ')" 153 | ] 154 | }, 155 | { 156 | "cell_type": "markdown", 157 | "metadata": { 158 | "ein.tags": "worksheet-0", 159 | "slideshow": { 160 | "slide_type": "-" 161 | } 162 | }, 163 | "source": [ 164 | " \n", 165 | " ==================================================\n", 166 | " b'\\n'\n", 167 | " 200\n", 168 | " text/html; charset=utf-8\n", 169 | " Connection: close\n", 170 | " Server: meinheld/0.6.1\n", 171 | " Date: Fri, 14 Jul 2017 07:54:28 GMT\n", 172 | " Content-Type: text/html; charset=utf-8\n", 173 | " Content-Length: 12793\n", 174 | " Access-Control-Allow-Origin: *\n", 175 | " Access-Control-Allow-Credentials: true\n", 176 | " X-Powered-By: Flask\n", 177 | " X-Processed-Time: 0.00643992424011\n", 178 | " Via: 1.1 vegur\n", 179 | "**最简单的Python Web服务器**\n", 180 | "\n", 181 | "```sh\n", 182 | "python -m http.server\n", 183 | "```\n", 184 | "\n", 185 | " Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...\n", 186 | " 127.0.0.1 - - [26/Apr/2017 09:50:56] \"GET / HTTP/1.1\" 200 -\n", 187 | " ...\n", 188 | "\n", 189 | "\n", 190 | "\n", 191 | "\n", 192 | "## 第三方工具和库\n", 193 | "\n", 194 | "\n", 195 | "\n", 196 | "\n", 197 | "### HTTPie\n", 198 | "\n", 199 | "\n", 200 | "\n", 201 | "HTTPie(读aych-tee-tee-pie)是一个 HTTP 的命令行客户端。 其目标是让 CLI 和 web 服务之间的交互尽可能的人性化。\n", 202 | "\n", 203 | "这个工具提供了简洁的http命令,允许通过自然的语法发送任意 HTTP 请求数据, 展示色彩化的输出。\n", 204 | "\n", 205 | "HTTPie 可用于与 HTTP 服务器做测试、调试和常规交互。\n", 206 | "\n", 207 | "HTTPie 用 Python 编写,用到了 Requests 和 Pygments 这些出色的库。\n", 208 | "\n", 209 | "\n", 210 | "\n", 211 | "\n", 212 | "### httpbin\n", 213 | "\n", 214 | "\n", 215 | "\n", 216 | "使用 Python + Flask 编写的 HTTP 请求和响应服务。\n", 217 | "\n", 218 | "**Installation**\n", 219 | "\n", 220 | "Run it as a WSGI app using Gunicorn:\n", 221 | "\n", 222 | "```sh\n", 223 | "$ pip install httpbin\n", 224 | "$ gunicorn httpbin:app\n", 225 | "```\n", 226 | "\n", 227 | " $ http http://httpbin.org/user-agent\n", 228 | " HTTP/1.1 200 OK\n", 229 | " Access-Control-Allow-Credentials: true\n", 230 | " Access-Control-Allow-Origin: *\n", 231 | " Connection: keep-alive\n", 232 | " Content-Length: 35\n", 233 | " Content-Type: application/json\n", 234 | " Date: Wed, 26 Apr 2017 04:32:28 GMT\n", 235 | " Server: gunicorn/19.7.1\n", 236 | " Via: 1.1 vegur\n", 237 | "\n", 238 | " {\n", 239 | " \"user-agent\": \"HTTPie/0.9.9\"\n", 240 | " }\n", 241 | "\n", 242 | "\n", 243 | "\n", 244 | "\n", 245 | "### Requests库\n", 246 | "\n", 247 | "> **HTTP for Humans.**\n", 248 | "\n", 249 | "Docs: \n", 250 | "\n", 251 | "Repo: " 252 | ] 253 | }, 254 | { 255 | "cell_type": "markdown", 256 | "metadata": { 257 | "ein.tags": "worksheet-0", 258 | "slideshow": { 259 | "slide_type": "-" 260 | } 261 | }, 262 | "source": [ 263 | "```python\n", 264 | ">>> r = requests.get('https://api.github.com/user', auth=('user', 'pass'))\n", 265 | ">>> r.status_code\n", 266 | "200\n", 267 | ">>> r.headers['content-type']\n", 268 | "'application/json; charset=utf8'\n", 269 | ">>> r.encoding\n", 270 | "'utf-8'\n", 271 | ">>> r.text\n", 272 | "u'{\"type\":\"User\"...'\n", 273 | ">>> r.json()\n", 274 | "{u'private_gists': 419, u'total_private_repos': 77, ...}\n", 275 | "```" 276 | ] 277 | }, 278 | { 279 | "cell_type": "code", 280 | "execution_count": null, 281 | "metadata": { 282 | "autoscroll": false, 283 | "collapsed": false, 284 | "ein.tags": "worksheet-0", 285 | "slideshow": { 286 | "slide_type": "-" 287 | } 288 | }, 289 | "outputs": [], 290 | "source": [ 291 | "import requests\n", 292 | "s = requests.Session()\n", 293 | "print(s.get('http://httpbin.org/ip').text)\n", 294 | "\n", 295 | "print(s.get('http://httpbin.org/get').json())\n", 296 | "\n", 297 | "print(s.post('http://httpbin.org/post',\n", 298 | " {'key':'value'},\n", 299 | " headers={'user-agent':'httpie'}).text)\n", 300 | "\n", 301 | "print(s.get('http://httpbin.org/status/404').status_code)\n", 302 | "\n", 303 | "print(s.get('http://httpbin.org/html').text)\n", 304 | "\n", 305 | "print(s.get('http://httpbin.org/deny').text)" 306 | ] 307 | }, 308 | { 309 | "cell_type": "markdown", 310 | "metadata": { 311 | "ein.tags": "worksheet-0", 312 | "slideshow": { 313 | "slide_type": "-" 314 | } 315 | }, 316 | "source": [ 317 | "\n", 318 | "\n", 319 | "## 简单Python Web架构\n", 320 | "\n", 321 | "![img](../images/python-web-simple-architecture.png)\n", 322 | "\n", 323 | "实际生产中,Python程序是放在服务器的 HTTP Server(比如 Apache,Nginx 等)上的。\n", 324 | "\n", 325 | "**服务器程序怎么把接受到的请求传递给Python?**\n", 326 | "\n", 327 | "**怎么在网络的数据流和Python的结构体之间转换?**\n", 328 | "\n", 329 | "处理上面两项工作就是图中 WSGI Server 做的事情。\n", 330 | "\n", 331 | "\n", 332 | "\n", 333 | "\n", 334 | "### Python WSGI\n", 335 | "\n", 336 | "WSGI(Web Server Gateway Interface)是一套关于程序端和服务器端的 **规范** ,或者说统一的接口。\n", 337 | "\n", 338 | "先看一下面向 HTTP 的 Python Web程序需要关心的内容:\n", 339 | "\n", 340 | "- 请求\n", 341 | " - **请求的方法 method**\n", 342 | " - **请求的地址 url**\n", 343 | " - **请求的内容**\n", 344 | " - 请求的头部 header\n", 345 | " - 请求的环境信息\n", 346 | "\n", 347 | "- 响应\n", 348 | " - **状态码 status\\_code**\n", 349 | " - **响应的数据**\n", 350 | " - 响应的头部\n", 351 | "\n", 352 | "WSGI的任务就是 **把上面的数据在 HTTP Server 和 Python 程序之间简单友好地传递。** 它是一个标准,被定义在[PEP 3333](https://www.python.org/dev/peps/pep-3333/#original-rationale-and-goals-from-pep-333)。需要 HTTP Server 和 Python 程序都要遵守一定的规范,实现这个标准的约定内容,才能正常工作。\n", 353 | "\n", 354 | "\n", 355 | "\n", 356 | "\n", 357 | "## Web框架有什么\n", 358 | "\n", 359 | "一个Web框架,至少要具备处理客户端请求和服务端响应的能力。\n", 360 | "\n", 361 | "- **路由:** 解析URL并找到对应的服务端文件或者Python服务器代码。\n", 362 | "- **模板:** 把服务端数据合并成HTML页面。\n", 363 | "- **认证和授权:** 处理用户名、密码和权限。\n", 364 | "- **Session:** 处理用户在多次请求之间需要存储的数据。\n", 365 | "\n", 366 | "\n", 367 | "\n", 368 | "\n", 369 | "## 开发环境及工具\n", 370 | "\n", 371 | "\n", 372 | "\n", 373 | "\n", 374 | "### 开发环境\n", 375 | "\n", 376 | "- **Ubuntu 16.04 LTS**\n", 377 | "- LinuxBrew(Optional)\n", 378 | "- **Python3.5+**\n", 379 | "- **virtualenvwrapper**\n", 380 | " - create a vritualenv\n", 381 | "\n", 382 | "\n", 383 | "\n", 384 | "\n", 385 | "### [Visual Studio Code](https://code.visualstudio.com)\n", 386 | "\n", 387 | "- 拓展:[Python](https://marketplace.visualstudio.com/items?itemName=donjayamanne.python), [MagicPython](https://marketplace.visualstudio.com/items?itemName=magicstack.MagicPython)\n", 388 | "- 用户配置文件\n", 389 | "\n", 390 | " ```js\n", 391 | " // python\n", 392 | " \"python.pythonPath\": \"/usr/local/bin/python3\",\n", 393 | " \"python.venvPath\": \"~/.virtualenvs\",\n", 394 | " \"python.jediPath\": \"ENV_PATH/lib/python3.6/site-packages\",\n", 395 | " \"python.linting.flake8Enabled\": true,\n", 396 | " \"python.linting.pylintEnabled\": false,\n", 397 | " \"python.formatting.provider\": \"yapf\",\n", 398 | " \"python.autoComplete.addBrackets\": false,\n", 399 | " \"python.autoComplete.extraPaths\": [\n", 400 | " \"ENV1_PATH/lib/python3.6/site-packages\",\n", 401 | " \"ENV2_PATH/lib/python3.6/site-packages\",\n", 402 | " \"ENV3_PATH/lib/python3.4/site-packages\"\n", 403 | " ]\n", 404 | " ```" 405 | ] 406 | } 407 | ], 408 | "metadata": { 409 | "kernelspec": { 410 | "display_name": "Python 3", 411 | "name": "python3" 412 | }, 413 | "language_info": { 414 | "codemirror_mode": { 415 | "name": "ipython", 416 | "version": 3 417 | }, 418 | "file_extension": ".py", 419 | "mimetype": "text/x-python", 420 | "name": "python", 421 | "nbconvert_exporter": "python", 422 | "pygments_lexer": "ipython3", 423 | "version": "3.6.2" 424 | }, 425 | "name": "1-python-web-dev-intro.ipynb", 426 | "toc": { 427 | "colors": { 428 | "hover_highlight": "#DAA520", 429 | "running_highlight": "#FF0000", 430 | "selected_highlight": "#FFD700" 431 | }, 432 | "moveMenuLeft": true, 433 | "nav_menu": { 434 | "height": "282px", 435 | "width": "252px" 436 | }, 437 | "navigate_menu": true, 438 | "number_sections": false, 439 | "sideBar": true, 440 | "threshold": 4, 441 | "toc_cell": false, 442 | "toc_position": { 443 | "height": "531px", 444 | "left": "0px", 445 | "right": "1020px", 446 | "top": "106px", 447 | "width": "242px" 448 | }, 449 | "toc_section_display": "block", 450 | "toc_window_display": false, 451 | "widenNotebook": false 452 | } 453 | }, 454 | "nbformat": 4, 455 | "nbformat_minor": 2 456 | } 457 | -------------------------------------------------------------------------------- /ch02/2-flask-intro.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": { 6 | "ein.tags": "worksheet-0", 7 | "slideshow": { 8 | "slide_type": "-" 9 | } 10 | }, 11 | "source": [ 12 | "# Flask框架简介\n", 13 | "Flask 是非常流行的Python Web框架。\n", 14 | "![img](../images/flask-logo.png)\n", 15 | "\n", 16 | "\n", 17 | "官方网站:\n", 18 | "\n", 19 | "github: \n", 20 | "\n", 21 | "\n", 22 | "## Flask特性\n", 23 | "\n", 24 | "- 非常齐全的官方文档\n", 25 | "- 社区活跃度非常高\n", 26 | "- 具备良好的扩展机制和第三方扩展环境\n", 27 | "\n", 28 | "- 微框架的形式给开发者很大的选择空间\n", 29 | "\n", 30 | "- Pocoo团队出品\n", 31 | "\n", 32 | "Flask 主要依赖三个库:\n", 33 | "\n", 34 | "- **[Werkzeug](http://werkzeug.pocoo.org/):** 路由、调试和 Web 服务器网关接口(Web Server Gateway Interface,WSGI)。\n", 35 | "- **[Jinja2](http://jinja.pocoo.org/):** 默认的模板引擎。\n", 36 | "- **[Itsdangerous](https://pythonhosted.org/itsdangerous/):** 基于[Django签名模块](https://docs.djangoproject.com/en/dev/topics/signing/)的签名实现。\n", 37 | "\n", 38 | "Flask 并不原生支持数据库访问、Web 表单验证和用户认证等高级功能。\n", 39 | "\n", 40 | "\n", 41 | "## 其他特性\n", 42 | "\n", 43 | "- 内置开发用服务器和debugger\n", 44 | "- 集成单元测试(unit testing)\n", 45 | "- RESTful request dispatching\n", 46 | "- 支持secure cookies(client side sessions)\n", 47 | "- 100% WSGI 1.0兼容\n", 48 | "- Unicode based\n", 49 | "- Google App Engine兼容\n", 50 | "\n", 51 | "\n", 52 | "## Flask 安装\n", 53 | "\n", 54 | "1. 创建虚拟环境\n", 55 | "\n", 56 | " 使用 **[virtualenvwrapper](https://virtualenvwrapper.readthedocs.io)** 创建一个虚拟环境(Python 3版本):\n", 57 | "\n", 58 | " ```sh\n", 59 | " mkvirtualenv -p python3.6 flaskr_env3\n", 60 | " ```\n", 61 | "\n", 62 | " 命令执行结束,自动激活虚拟环境 flaskr\\_env3 (只会影响当前的命令行会话)。命令行提示符会被修改,加入环境名:\n", 63 | "\n", 64 | " (flaskr_env3) $\n", 65 | "\n", 66 | " 在命令行提示符下输入deactivate取消激活虚拟环境:\n", 67 | "\n", 68 | " (flaskr_env3) $ deactivate\n", 69 | "\n", 70 | "2. 使用 **pip** 安装Python包\n", 71 | "\n", 72 | " 执行下述命令在虚拟环境中安装 Flask:\n", 73 | "\n", 74 | " (flaskr_env3) $ pip install flask\n", 75 | "\n", 76 | " 其会自动安装 Flask 及其依赖。安装完之后,可以验证 Flask 是否安装成功,启动 Python 解释器,尝试导入 Flask:\n", 77 | "\n", 78 | " (flaskr_env3) $ python\n", 79 | " >>> import flask\n", 80 | " >>>\n", 81 | "\n", 82 | " 如果没有错误提示,则表示 Flask 安装成功。\n", 83 | "\n", 84 | "\n", 85 | "## 一个完整的程序\n", 86 | "\n", 87 | "```python\n", 88 | "# hello.py\n", 89 | "from flask import Flask\n", 90 | "app = Flask(__name__)\n", 91 | "\n", 92 | "\n", 93 | "@app.route('/')\n", 94 | "def index():\n", 95 | " return '

Hello World!

'\n", 96 | "```\n", 97 | "\n", 98 | "要想运行这个程序,请确保激活上面创建的虚拟环境,并在其中安装好了 Flask 。\n", 99 | "设置环境变量:\n", 100 | "\n", 101 | "```\n", 102 | "(flaskr_env3) $ export FLASK_APP=hello.py\n", 103 | "(flaskr_env3) $ export FLASK_DEBUG=1\n", 104 | "```\n", 105 | "\n", 106 | "使用下述命令启动程序:\n", 107 | "\n", 108 | " (flaskr_env3) $ flask run\n", 109 | " * Serving Flask app \"hello\"\n", 110 | " * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)\n", 111 | "\n", 112 | "打开Web浏览器,在地址栏中输入http://127.0.0.1:5000/。\n", 113 | "\n", 114 | "**执行git checkout 1a签出程序的这个版本。**\n", 115 | "\n", 116 | "\n", 117 | "## 调试模式\n", 118 | "\n", 119 | "上面启动命令中通过export FLASK_DEBUG=1启用调试模式,服务器会在代码修改后自动重新载入。\n", 120 | "\n", 121 | "如果FLASK_DEBUG设置为0则将关闭调试模式。\n", 122 | "\n", 123 | "## 动态路由\n", 124 | "```python\n", 125 | "# hello.py\n", 126 | "from flask import Flask\n", 127 | "app = Flask(__name__)\n", 128 | "\n", 129 | "\n", 130 | "@app.route('/')\n", 131 | "def index():\n", 132 | " return '

Hello World!

'\n", 133 | "\n", 134 | "\n", 135 | "@app.route('/user/')\n", 136 | "def user(name):\n", 137 | " return '

Hello, %s!

' % name\n", 138 | "```\n", 139 | "\n", 140 | "访问http://localhost:5000/user/david,程序会显示一个使用name动态参数生成的欢迎消息。可尝试使用不同的名字,可看到视图函数总是使用指定的名字生成响应。\n", 141 | "\n", 142 | "**执行git checkout 1b签出程序的这个版本。**\n", 143 | "\n", 144 | "\n", 145 | "### 动态URL规则\n", 146 | "\n", 147 | "URL规则可以添加变量部分,即将符合同种规则的URL抽象称一个URL模式,如/item/1//item/2//item/3/… 假如不抽象,就得这样写:\n", 148 | "\n", 149 | "```python\n", 150 | "@app.route('/item/1/')\n", 151 | "@app.route('/item/2/')\n", 152 | "@app.route('/item/3/')\n", 153 | "def item(id):\n", 154 | " return 'Item: {}'.format(id)\n", 155 | "```\n", 156 | "\n", 157 | "正确的用法是:\n", 158 | "\n", 159 | "```python\n", 160 | "@app.route('/item//')\n", 161 | "def item(id):\n", 162 | " return 'Item: {}'.format(id)\n", 163 | "```\n", 164 | "\n", 165 | "**尖括号** 中的内容是动态的,凡是匹配到/item/前缀的URL都会被映射到这个路由上,在 内部把id作为参数而获得。\n", 166 | "\n", 167 | "它使用了特殊的字段标记 **** ,默认类型是字符串。如果需要指定参数类型则可 标记成 **** 这样的格式, **converter** 有以下几种:\n", 168 | "\n", 169 | "- **`string`:** 接受任何没有斜杠 “/” 的文本(默认)\n", 170 | "- **`int`:** 接受整数\n", 171 | "- **`float`:** 同int,但是接受浮点数\n", 172 | "- **`path`:** 和默认的相似,但也接受斜杠\n", 173 | "- **`uuid`:** 只接受uuid字符串\n", 174 | "- **`any`:** 可以指定多种路径,但是需要传入参数\n", 175 | "\n", 176 | " `@app.route('//')`\n", 177 | "\n", 178 | " 访问/a/和 访问/b/都符合这个规则,/a/对应的page_name就是a。\n", 179 | "\n", 180 | "如果不希望定制子路径,还可以通过 **传递参数** 的方式。比如/people/?name=a/people/?name=b,这样即可通过name = request.args.get('name')获得传入的name值。\n", 181 | "\n", 182 | "如果使用 **POST** 方法,表单参数需要通过request.form.get('name')获得。\n", 183 | "\n", 184 | "\n", 185 | "### 唯一URL\n", 186 | "\n", 187 | "Flask的URL规则基于Werkzeug的路由模块,这个模块背后的思想是希望保证优雅且唯一的URL。\n", 188 | "\n", 189 | "举个例子:\n", 190 | "\n", 191 | "```python\n", 192 | "@app.route('/projects/')\n", 193 | "def projects():\n", 194 | " return 'The project page'\n", 195 | "```\n", 196 | "\n", 197 | "**访问一个结尾不带斜线的URL会被重定向到带斜线的规范的URL上去** 。\n", 198 | "\n", 199 | "再看一个例子:\n", 200 | "\n", 201 | "```python\n", 202 | "@app.route('/about')\n", 203 | "def about():\n", 204 | " return 'The about page'\n", 205 | "```\n", 206 | "\n", 207 | "URL结尾不带斜线,很像文件的路径,当访问带斜线的URL(/about/)会产生一个404 “Not Found” 错误。\n", 208 | "\n", 209 | "\n", 210 | "## 请求—响应循环\n", 211 | "\n", 212 | "接下来介绍 Flask 的工作方式,了解这个框架的一些设计理念。\n", 213 | "\n", 214 | "\n", 215 | "### 程序和请求上下文\n", 216 | "\n", 217 | "Flask 从客户端收到请求时,要让视图函数能访问一些对象来处理请求。 **请求对象** 封装了客户端发送的 HTTP 请求。\n", 218 | "\n", 219 | "要想让视图函数能够访问请求对象,一个方式是 **将其作为参数传入视图函数** , 不过这会导致程序中的每个视图函数都增加一个参数。 如果视图函数在处理请求时还要访问其他对象,情况会变得更糟。\n", 220 | "\n", 221 | "为了避免大量可有可无的参数把视图函数弄得一团糟,Flask 使用 **上下文** 临时把某些对象变为 **全局可访问** 。 视图函数中使用上下文:\n", 222 | "\n", 223 | "```python\n", 224 | "from flask import request\n", 225 | "\n", 226 | "@app.route('/')\n", 227 | "def index():\n", 228 | " user_agent = request.headers.get('User-Agent')\n", 229 | " return '

Your browser is %s

' % user_agent\n", 230 | "```\n", 231 | "\n", 232 | "上面这个视图函数把request当作全局变量使用。 Flask 使用上下文让特定的变量在一个线程中全局可访问,与此同时却不会干扰其他线程。1\n", 233 | "\n", 234 | "Flask 中有两种上下文: **程序上下文** 和 **请求上下文** 。\n", 235 | "\n", 236 | "| 变量名 | 上下文 | 说明 |\n", 237 | "|---------------|--------|-----------------------------|\n", 238 | "| `current_app` | 程序上下文 | 当前激活程序的程序实例 |\n", 239 | "| `g` | 程序上下文 | 处理请求时用作临时存储的对象,每次请求都会重设这个变量 |\n", 240 | "| `request` | 请求上下文 | 请求对象,封装了客户端发出的 HTTP 请求中的内容 |\n", 241 | "| `session` | 请求上下文 | 用户会话,用于存储请求之间需要“记住”的值的词典 |\n", 242 | "\n", 243 | "Flask 在分发请求之前激活(或推送)程序和请求上下文,请求处理完成后再将其删除。 程序上下文被推送后,就可以在线程中使用current_appg变量。 类似地,请求上下文被推送后,就可以使用requestsession变量。 如果使用这些变量时没有激活程序上下文或请求上下文,就会导致错误。\n", 244 | "\n", 245 | "下面的Python shell会话演示了程序上下文的使用方法:\n", 246 | "\n", 247 | "```python\n", 248 | ">>> from hello import app\n", 249 | ">>> from flask import current_app\n", 250 | ">>> current_app.name\n", 251 | "Traceback (most recent call last):\n", 252 | "...\n", 253 | "RuntimeError: Working outside of application context.\n", 254 | "...\n", 255 | ">>> app_ctx = app.app_context()\n", 256 | ">>> app_ctx.push()\n", 257 | ">>> current_app.name\n", 258 | "'hello'\n", 259 | ">>> app_ctx.pop()\n", 260 | "```\n", 261 | "\n", 262 | "没激活程序上下文之前就调用current_app.name会导致错误,推送完上下文之后才可以调用。\n", 263 | "\n", 264 | "**注意:** 调用app.app_context()可获得一个程序上下文。\n", 265 | "\n", 266 | "### 请求调度\n", 267 | "\n", 268 | "程序收到客户端发来的请求时,要找到处理该请求的视图函数。\n", 269 | "\n", 270 | "**Flask 会在程序的 URL 映射中查找请求的 URL。** URL 映射是 URL 和视图函数之间的对应关系。 Flask 使用app.route装饰器或者非装饰器形式的app.add_url_rule()生成映射。\n", 271 | "\n", 272 | "在 Python shell 中检查为hello.py生成的映射:\n", 273 | "\n", 274 | "```python\n", 275 | "(flaskr_env3) $ python\n", 276 | ">>> from hello import app\n", 277 | ">>> app.url_map\n", 278 | "Map([ index>,\n", 279 | " ' (OPTIONS, GET, HEAD) -> static>,\n", 280 | " ' (OPTIONS, GET, HEAD) -> user>])\n", 281 | "```\n", 282 | "\n", 283 | "- //user/路由在程序中使用app.route装饰器定义。\n", 284 | "- **`/static/` 是 Flask 添加的特殊路由,用于访问静态文件。**\n", 285 | "\n", 286 | "URL 映射中的 **HEAD** 、 **OPTIONS** 、 **GET** 是请求方法,由路由进行处理。 Flask 为每个路由都指定了请求方法,这样不同的请求方法发送到相同的 URL 上时, 会使用不同的视图函数进行处理。 **HEAD** 和 **OPTIONS** 方法由 Flask 自动处理,在这个程序中, URL 映射中的 3 个路由都使用 **GET** 方法。\n", 287 | "\n", 288 | "\n", 289 | "### 请求钩子\n", 290 | "\n", 291 | "在处理请求之前或之后执行代码有时会很有用。 例如,在请求开始时,可能需要创建数据库连接或者认证发起请求的用户。 为了避免在每个视图函数中都使用重复的代码, Flask 提供了注册通用函数的功能, 注册的函数可在请求被分发到视图函数之前或之后调用。\n", 292 | "\n", 293 | "请求钩子使用装饰器实现。Flask 支持以下 4 种钩子:\n", 294 | "\n", 295 | "- **`before_first_request`:** 注册一个函数,在处理第一个请求之前运行。\n", 296 | "- **`before_request`:** 注册一个函数,在每次请求之前运行。\n", 297 | "- **`after_request`:** 注册一个函数,如果没有未处理的异常抛出,在每次请求之后运行。\n", 298 | "- **`teardown_request`:** 注册一个函数,即使有未处理的异常抛出,也在每次请求之后运行。\n", 299 | "\n", 300 | "**在请求钩子函数和视图函数之间共享数据一般使用上下文全局变量g。** 例如,before_request处理程序可以从数据库中加载已登录用户,并将其保存到g.user中。 随后调用视图函数时,视图函数再使用g.user获取用户。\n", 301 | "\n", 302 | "\n", 303 | "### 响应\n", 304 | "\n", 305 | "Flask 调用视图函数后,会将其返回值作为响应的内容。大多数情况下,响应就是一个简 单的字符串,作为 HTML 页面回送给客户端。\n", 306 | "\n", 307 | "HTTP 响应中一个很重要的部分是 **状态码** , Flask 默认设为 **200** ,这个代码表明请求已经被成功处理。\n", 308 | "\n", 309 | "如果视图函数返回的响应需要使用不同的状态码,那么可以把数字代码作为第二个返回 值,添加到响应文本之后。\n", 310 | "\n", 311 | "```python\n", 312 | "@app.route('/')\n", 313 | "def index():\n", 314 | " return '

Bad Request

', 400\n", 315 | "```\n", 316 | "\n", 317 | "视图函数返回的响应还可接受第三个参数,这是一个由首部(header)组成的字典, 可以添加到 HTTP 响应中。\n", 318 | "\n", 319 | "除了元组以外, Flask 视图函数还可以返回 Response 对象。make_response函数 接受1-3个参数(和视图函数的返回值一样),并返回一个Response对象。\n", 320 | "\n", 321 | "下例中创建了一个响应对象,然后设置了cookie:\n", 322 | "\n", 323 | "```python\n", 324 | "from flask import make_response\n", 325 | "\n", 326 | "@app.route('/')\n", 327 | "def index():\n", 328 | " response = make_response('

This document carries a cookie!

')\n", 329 | " response.set_cookie('answer', '42')\n", 330 | " return response\n", 331 | "```\n", 332 | "\n", 333 | "有一种名为 **重定向** 的特殊响应类型,经常在 Web 表单中使用。 这种响应没有页面文档,只告诉浏览器一个新地址用以加载新页面。\n", 334 | "\n", 335 | "重定向经常使用 **302** 状态码表示,指向的地址由Location首部提供。 重定向响应可以使用 3 个值形式的返回值生成,也可在Response对象中设定。 Flask 还提供了redirect()辅助函数,用于生成这种响应:\n", 336 | "\n", 337 | "```python\n", 338 | "from flask import redirect\n", 339 | "\n", 340 | "@app.route('/')\n", 341 | "def index():\n", 342 | " return redirect('http://www.example.com')\n", 343 | "```\n", 344 | "\n", 345 | "还有一种特殊的响应由abort函数生成,用于处理错误。 下例中,如果 URL 中动态参数id对应的用户不存在,就返回状态码 **404** :\n", 346 | "\n", 347 | "```python\n", 348 | "from flask import abort\n", 349 | "\n", 350 | "@app.route('/user/')\n", 351 | "def get_user(id):\n", 352 | " user = load_user(id)\n", 353 | " if not user:\n", 354 | " abort(404)\n", 355 | " return '

Hello, %s

' % user.name\n", 356 | "```\n", 357 | "\n", 358 | "abort不会把控制权交还给调用它的函数,而是抛出异常把控制权交给 Web 服务器。\n", 359 | "\n", 360 | "## 脚注\n", 361 | "\n", 362 | "1 多线程 Web 服务器会创建一个线程池,再从线程池中选择一个线程用于处理接收到的请求。\n" 363 | ] 364 | } 365 | ], 366 | "metadata": { 367 | "kernelspec": { 368 | "display_name": "Python 3", 369 | "name": "python3" 370 | }, 371 | "language_info": { 372 | "codemirror_mode": { 373 | "name": "ipython", 374 | "version": 3 375 | }, 376 | "file_extension": ".py", 377 | "mimetype": "text/x-python", 378 | "name": "python", 379 | "nbconvert_exporter": "python", 380 | "pygments_lexer": "ipython3", 381 | "version": "3.6.2" 382 | }, 383 | "name": "2-flask-intro.ipynb", 384 | "toc": { 385 | "colors": { 386 | "hover_highlight": "#ddd", 387 | "running_highlight": "#FF0000", 388 | "selected_highlight": "#ccc" 389 | }, 390 | "moveMenuLeft": true, 391 | "nav_menu": { 392 | "height": "282px", 393 | "width": "252px" 394 | }, 395 | "navigate_menu": true, 396 | "number_sections": false, 397 | "sideBar": true, 398 | "threshold": 4, 399 | "toc_cell": false, 400 | "toc_section_display": "block", 401 | "toc_window_display": false, 402 | "widenNotebook": false 403 | } 404 | }, 405 | "nbformat": 4, 406 | "nbformat_minor": 2 407 | } 408 | -------------------------------------------------------------------------------- /ch03/3-jinja2-templates.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": { 6 | "ein.tags": "worksheet-0", 7 | "slideshow": { 8 | "slide_type": "-" 9 | } 10 | }, 11 | "source": [ 12 | "# 模板\n", 13 | "把业务逻辑和表现逻辑混在一起会导致代码难以理解和维护。为了提升程序的可维护性,通常把表现逻辑移到 **模板** 中。\n", 14 | "\n", 15 | "**模板** 是一个包含响应文本的文件,其中包含用占位变量表示的动态部分, 其具体值只在请求的上下文中才能知道。使用真实值替换变量,再返回最终得到的响应字符串, 这一过程称为 **渲染** 。为了渲染模板,Flask 使用了一个名为 **[Jinja2](http://jinja.pocoo.org/)** 的强大模板引擎。\n", 16 | "\n", 17 | "\n", 18 | "## Jinja2模板引擎\n", 19 | "\n", 20 | "\n", 21 | "### 基本使用\n", 22 | "\n", 23 | "Jinja2通过`Template`类创建并渲染模板:" 24 | ] 25 | }, 26 | { 27 | "cell_type": "code", 28 | "execution_count": null, 29 | "metadata": { 30 | "autoscroll": false, 31 | "collapsed": false, 32 | "ein.tags": "worksheet-0", 33 | "slideshow": { 34 | "slide_type": "-" 35 | } 36 | }, 37 | "outputs": [], 38 | "source": [ 39 | "from jinja2 import Template\n", 40 | "template = Template('Hello {{ name }}!')\n", 41 | "print(template.render(name='Xiao Ming'))" 42 | ] 43 | }, 44 | { 45 | "cell_type": "markdown", 46 | "metadata": { 47 | "ein.tags": "worksheet-0", 48 | "slideshow": { 49 | "slide_type": "-" 50 | } 51 | }, 52 | "source": [ 53 | "其背后通过`Environment`实例来存储配置和全局对象,从文件系统或其他位置加载模板:\n", 54 | "\n", 55 | "```django\n", 56 | "{# templates/user.html #}\n", 57 | "

Hello, {{ name }}!

\n", 58 | "```" 59 | ] 60 | }, 61 | { 62 | "cell_type": "code", 63 | "execution_count": null, 64 | "metadata": { 65 | "autoscroll": false, 66 | "collapsed": false, 67 | "ein.tags": "worksheet-0", 68 | "slideshow": { 69 | "slide_type": "-" 70 | } 71 | }, 72 | "outputs": [], 73 | "source": [ 74 | "# IPython magic command\n", 75 | "%cd -q ~/Documents/WebApp/flaskr/\n", 76 | "!git checkout -q 2a\n", 77 | "\n", 78 | "from jinja2 import Environment, PackageLoader\n", 79 | "\n", 80 | "\n", 81 | "env = Environment(loader=PackageLoader('hello', 'templates'))\n", 82 | "template = env.get_template('user.html')\n", 83 | "template.render(name='Xiao Ming')" 84 | ] 85 | }, 86 | { 87 | "cell_type": "markdown", 88 | "metadata": { 89 | "ein.tags": "worksheet-0", 90 | "slideshow": { 91 | "slide_type": "-" 92 | } 93 | }, 94 | "source": [ 95 | "通过`Environment`创建了一个模板环境,模板加载器(loader)会在`templates`文件夹中寻找模板。\n", 96 | "### Flask中使用模板\n", 97 | "\n", 98 | "默认情况下,Flask 在程序文件夹中的`templates`子文件夹中寻找模板。\n", 99 | "\n", 100 | "形式最简单的 Jinja2 模板就是一个包含响应文本的文件。\n", 101 | "\n", 102 | "首先创建`templates`子文件夹,然后在其中创建`index.html`和`user.html`文件。\n", 103 | "\n", 104 | "```django\n", 105 | "{# templates/index.html #}\n", 106 | "

Hello World!

\n", 107 | "```\n", 108 | "\n", 109 | "视图函数`user()`返回的响应中包含一个使用变量表示的动态部分。 下例使用模板实现这个响应:\n", 110 | "\n", 111 | "```django\n", 112 | "{# templates/user.html #}\n", 113 | "

Hello, {{ name }}!

\n", 114 | "```\n", 115 | "\n", 116 | "\n", 117 | "### 渲染模板\n", 118 | "\n", 119 | "接下来为`hello.py`增加模板渲染。\n", 120 | "\n", 121 | "修改程序中的视图函数,以便渲染上面创建的2个模板。\n", 122 | "\n", 123 | "```python\n", 124 | "# hello.py\n", 125 | "from flask import Flask, render_template\n", 126 | "\n", 127 | "# ...\n", 128 | "\n", 129 | "@app.route('/')\n", 130 | "def index():\n", 131 | " return render_template('index.html')\n", 132 | "\n", 133 | "\n", 134 | "@app.route('/user/')\n", 135 | "def user(name):\n", 136 | " return render_template('user.html', name=name)\n", 137 | "```\n", 138 | "\n", 139 | "Flask 提供的`render_template`函数把 Jinja2 模板引擎集成到了程序中。`render_template`函数的第一个参数是模板的文件名。 随后的参数都是键值对,表示模板中变量对应的真实值。\n", 140 | "\n", 141 | "**🔖 执行`git checkout 2a`签出程序的这个版本。**\n", 142 | "\n", 143 | "\n", 144 | "### 变量和过滤器\n", 145 | "\n", 146 | "在模板中使用的`{{ name }}`结构表示一个变量,它是一种特殊的占位符, 告诉模板引擎这个位置的值从渲染模板时使用的数据中获取。\n", 147 | "\n", 148 | "Jinja2 能识别所有类型的变量,甚至是一些复杂的类型,例如列表、字典和对象。\n", 149 | "\n", 150 | "使用变量的一些示例:\n", 151 | "\n", 152 | "```django\n", 153 | "

A value from a dictionary: {{ mydict['key'] }}.

\n", 154 | "

A value from a list: {{ mylist[3] }}.

\n", 155 | "

A value from a list, with a variable index: {{ mylist[myintvar] }}.

\n", 156 | "

A value from an object's method: {{ myobj.somemethod() }}.

\n", 157 | "```\n", 158 | "\n", 159 | "可以使用 **过滤器** 修改变量,过滤器名添加在变量名之后,中间使用竖线(`|`)分隔。\n", 160 | "\n", 161 | "```django\n", 162 | "{# 以首字母大写形式显示变量 name 的值 #}\n", 163 | "Hello, {{ name|capitalize }}\n", 164 | "```\n", 165 | "\n", 166 | "下标列举了 Jinja2 提供的部分常用过滤器1:\n", 167 | "\n", 168 | "| 过滤器名 | 说明 |\n", 169 | "|--------------|-----------------------|\n", 170 | "| `safe` | 渲染值时不转义 |\n", 171 | "| `capitalize` | 把值的首字母转换成大写,其他字母转换成小写 |\n", 172 | "| `lower` | 把值转换成小写形式 |\n", 173 | "| `upper` | 把值转换成大写形式 |\n", 174 | "| `title` | 把值中每个单词的首字母都转换成大写 |\n", 175 | "| `trim` | 把值的首尾空格去掉 |\n", 176 | "| `striptags` | 渲染之前把值中所有的HTML标签都删掉 |\n", 177 | "\n", 178 | "**默认情况下,出于安全考虑,Jinja2 会转义所有变量。** 例如,如果一个变量的值为`'

Hello

'`,Jinja2 会将其渲染成`'<h1>Hello</h1>'`,浏览器能显示这个`h1`元素,但不会进行解释。 **很多情况下需要显示变量中存储的 HTML 代码,这时就可使用`safe`过滤器。**\n", 179 | "\n", 180 | "**注意:** 千万别在不可信的值上使用`safe`过滤器,例如用户在表单中输入的文本。可能会引发 **跨站脚本攻击(XSS)** 。\n", 181 | "\n", 182 | "\n", 183 | "### 控制结构\n", 184 | "\n", 185 | "Jinja2 提供了多种控制结构,可用来改变模板的渲染流程。\n", 186 | "\n", 187 | "\n", 188 | "#### 条件控制\n", 189 | "\n", 190 | "```django\n", 191 | "{% if user %}\n", 192 | " Hello, {{ user }}!\n", 193 | "{% else %}\n", 194 | " Hello, Stranger!\n", 195 | "{% endif %}\n", 196 | "```\n", 197 | "\n", 198 | "\n", 199 | "#### `for` 循环\n", 200 | "\n", 201 | "```django\n", 202 | "
    \n", 203 | " {% for comment in comments %}\n", 204 | "
  • {{ comment }}
  • \n", 205 | " {% endfor %}\n", 206 | "
\n", 207 | "```\n", 208 | "\n", 209 | "\n", 210 | "### 宏\n", 211 | "\n", 212 | "宏类似于 Python 代码中的函数。\n", 213 | "\n", 214 | "```django\n", 215 | "{% macro render_comment(comment) %}\n", 216 | "
  • {{ comment }}
  • \n", 217 | "{% endmacro %}\n", 218 | "\n", 219 | "
      \n", 220 | " {% for comment in comments %}\n", 221 | " {{ render_comment(comment) }}\n", 222 | " {% endfor %}\n", 223 | "
    \n", 224 | "```\n", 225 | "\n", 226 | "为了重复使用宏,可以将其保存在单独的文件中,然后在需要使用的模板中导入:\n", 227 | "\n", 228 | "```django\n", 229 | "{% import 'macros.html' as macros %}\n", 230 | "\n", 231 | "
      \n", 232 | " {% for comment in comments %}\n", 233 | " {{ macros.render_comment(comment) }}\n", 234 | " {% endfor %}\n", 235 | "
    \n", 236 | "```\n", 237 | "\n", 238 | "导入使用与Python中类似的`import`语句,可以直接把整个模板导入到一个变量(`import xxx as yyy`),像上面那样,或者从其中导入特定的宏(`from xxx import yyy`)。\n", 239 | "\n", 240 | "```django\n", 241 | "{% from 'macros.html' import render_comment as r_comment %}\n", 242 | "```\n", 243 | "\n", 244 | "\n", 245 | "### include 包含\n", 246 | "\n", 247 | "为了避免重复,需要在多处重复使用的模板代码片段可以写入单独的文件,再包含在所有模板中。\n", 248 | "\n", 249 | "```django\n", 250 | "{% include 'common.html' %}\n", 251 | "```\n", 252 | "\n", 253 | "渲染时会在`include`语句的对应位置添加被包含的模板内容:\n", 254 | "\n", 255 | "```django\n", 256 | "{% include \"header.html\" %}\n", 257 | " Body\n", 258 | "{% include \"footer.html\" %}\n", 259 | "```\n", 260 | "\n", 261 | "`include`可以使用`ignore missing`标记,如果模板不存在,会直接忽略:\n", 262 | "\n", 263 | "```django\n", 264 | "{% include \"sidebar.html\" ignore missing %}\n", 265 | "```\n", 266 | "\n", 267 | "\n", 268 | "### 模板继承\n", 269 | "\n", 270 | "类似于 Python 代码中的类继承。合理使用模板继承,让模板能重用,能提高工作效率和代码质量。\n", 271 | "\n", 272 | "首先,创建一个名为`base.html`的基模板:\n", 273 | "\n", 274 | "```django\n", 275 | "\n", 276 | " \n", 277 | " {% block head %}\n", 278 | " {% block title %}{% endblock %} - My Application\n", 279 | " {% endblock %}\n", 280 | " \n", 281 | " \n", 282 | " {% block body %}\n", 283 | " {% endblock %}\n", 284 | " \n", 285 | "\n", 286 | "```\n", 287 | "\n", 288 | "`block`标签定义的元素可在衍生模板(子模板)中重载,如果子模板没有重载,就用基模板的定义 显示默认内容。 上面例子中,定义了名为`head`、`title`和`body`的块。`title`包含在`head`中。\n", 289 | "\n", 290 | "下面是基模板的衍生模板:\n", 291 | "\n", 292 | "```django\n", 293 | "{% extends \"base.html\" %}\n", 294 | "\n", 295 | "{% block title %}Index{% endblock %}\n", 296 | "{% block head %}\n", 297 | " {{ super() }}\n", 298 | " \n", 300 | "{% endblock %}\n", 301 | "{% block body %}\n", 302 | "

    Hello, World!

    \n", 303 | "{% endblock %}\n", 304 | "```\n", 305 | "\n", 306 | "`extends`指令声明这个模板衍生自`base.html`。 在`extends`指令之后,基模板中的 3 个块被重新定义,模板引擎会将其插入适当的位置。 **注意:** 新定义的`head`块,在基模板中其内容不是空的,所以使用`super()`获取原来的内容。\n", 307 | "\n", 308 | "\n", 309 | "### 赋值\n", 310 | "\n", 311 | "在代码块中使用`set`标签为变量赋值,并且可以为多个变量赋值:\n", 312 | "\n", 313 | "```ipython\n", 314 | "from jinja2 import Template\n", 315 | "\n", 316 | "print(Template(\"\"\"\n", 317 | "{% set a = 1 %}\n", 318 | "{% set b, c = range(2) %}\n", 319 | "

    {{ a }} {{ b }} {{ c }}

    \n", 320 | "\"\"\").render())\n", 321 | "```\n", 322 | "\n", 323 | "\n", 324 | "\n", 325 | "\n", 326 | "

    1 0 1

    \n", 327 | "\n", 328 | "\n", 329 | "## 使用 Flask-Bootstrap\n", 330 | "\n", 331 | "Bootstrap是非常流行的前端开发框架。\n", 332 | "\n", 333 | "要在程序中集成Bootstrap,需要对模板进行修改,加入 Bootstrap 层叠样式表(CSS) 和 JavaScript 文件的引用。但是,更简单的办法是直接使用Flask扩展 [Flask-Bootstrap](http://pythonhosted.org/Flask-Bootstrap/) 。\n", 334 | "\n", 335 | "\n", 336 | "### 安装 Flask-Bootstrap\n", 337 | "\n", 338 | "```sh\n", 339 | "(flaskr_env3) $ pip install flask-bootstrap\n", 340 | "```\n", 341 | "\n", 342 | "Flask 扩展一般都在创建程序实例时初始化:\n", 343 | "\n", 344 | "```python\n", 345 | "# hello.py\n", 346 | "from flask_bootstrap import Bootstrap\n", 347 | "\n", 348 | "# ...\n", 349 | "bootstrap = Bootstrap(app)\n", 350 | "```\n", 351 | "\n", 352 | "导入`Bootstrap`,然后把程序实例传入构造方法进行初始化。\n", 353 | "\n", 354 | "初始化 Flask-Bootstrap 之后,就可以在程序中使用一个包含所有 Bootstrap 文件的基模板。 这个模板利用 Jinja2 的模板继承机制,让程序扩展一个具有基本页面结构的基模板, 其中就有用来引入 Bootstrap 的元素。\n", 355 | "\n", 356 | "```django\n", 357 | "{# templates/base.html #}\n", 358 | "\n", 359 | "{% extends \"bootstrap/base.html\" %}\n", 360 | "\n", 361 | "{% block title %}Flaskr{% endblock %}\n", 362 | "\n", 363 | "{% block navbar %}\n", 364 | "
    \n", 365 | "
    \n", 366 | "
    \n", 367 | " \n", 374 | " Flaskr\n", 375 | "
    \n", 376 | "
    \n", 377 | "
      \n", 378 | "
    • Home
    • \n", 379 | "
    \n", 380 | "
    \n", 381 | "
    \n", 382 | "
    \n", 383 | "{% endblock %}\n", 384 | "\n", 385 | "{% block content %}\n", 386 | "
    \n", 387 | " {% block page_content %}{% endblock %}\n", 388 | "
    \n", 389 | "{% endblock %}\n", 390 | "```\n", 391 | "\n", 392 | "```django\n", 393 | "{# templates/index.html #}\n", 394 | "\n", 395 | "{% extends \"base.html\" %}\n", 396 | "\n", 397 | "{% block page_content %}\n", 398 | "
    \n", 399 | "

    Hello, world!

    \n", 400 | "
    \n", 401 | "{% endblock %}\n", 402 | "```\n", 403 | "\n", 404 | "```django\n", 405 | "{# templates/user.html #}\n", 406 | "\n", 407 | "{% extends \"index.html\" %}\n", 408 | "\n", 409 | "{% block page_content %}\n", 410 | "\t
    \n", 411 | "

    Hello, {{ name }}!

    \n", 412 | "
    \n", 413 | "{% endblock %}\n", 414 | "```\n", 415 | "\n", 416 | "Jinja2 中 的`extends`指令从 Flask-Bootstrap 中导入`bootstrap/base.html`, 从而实现模板继承。Flask-Bootstrap 中的基模板提供了一个网页框架, 引入了 Bootstrap 中的所有 CSS 和 JavaScript 文件。\n", 417 | "\n", 418 | "**🔖 执行`git checkout 2b`签出程序的这个版本。**" 419 | ] 420 | }, 421 | { 422 | "cell_type": "markdown", 423 | "metadata": { 424 | "ein.tags": "worksheet-0", 425 | "slideshow": { 426 | "slide_type": "-" 427 | } 428 | }, 429 | "source": [ 430 | "### Flask-Bootstrap基模板中定义的Block\n", 431 | "\n", 432 | "Flask-Bootstrap 的`base.html`模板还定义了很多其他块,都可在衍生模板中使用。\n", 433 | "\n", 434 | "| block名 | 外层block | 说明 |\n", 435 | "|-------------------|-----------|--------------------|\n", 436 | "| **doc** | | 整个HTML文档 |\n", 437 | "| **html** | **doc** | ``标签中的内容 |\n", 438 | "| **html\\_attribs** | **doc** | ``标签的属性 |\n", 439 | "| **head** | **doc** | ``标签中的内容 |\n", 440 | "| **title** | **head** | ``标签中的内容 |\n", 441 | "| **metas** | **head** | 一组`<meta>`标签 |\n", 442 | "| **styles** | **head** | 层叠样式表定义 |\n", 443 | "| **body** | **doc** | `<body>`标签中的内容 |\n", 444 | "| **body\\_attribs** | **body** | `<body>`标签的属性 |\n", 445 | "| **navbar** | **body** | 用户定义的导航条 |\n", 446 | "| **content** | **body** | 用户定义的页面内容 |\n", 447 | "| **scripts** | **body** | 文档底部的 JavaScript 声明 |\n", 448 | "\n", 449 | "下面是一些例子:\n", 450 | "\n", 451 | "- 添加自定义CSS文件\n", 452 | "\n", 453 | " ```django\n", 454 | " {% block styles %}\n", 455 | " {{super()}}\n", 456 | " <link rel=\"stylesheet\"\n", 457 | " href=\"{{ url_for('.static', filename='mystyle.css') }}\">\n", 458 | " {% endblock %}\n", 459 | " ```\n", 460 | "\n", 461 | "- 在Bootstrap JavaScript文件之前自定义加载的JavaScript文件\n", 462 | "\n", 463 | " ```django\n", 464 | " {% block scripts %}\n", 465 | " <script src=\"{{ url_for('.static', filename='myscripts.js') }}\"></script>\n", 466 | " {{ super() }}\n", 467 | " {% endblock %}\n", 468 | " ```\n", 469 | "\n", 470 | "- 给`<html>`标签添加`lang`属性\n", 471 | "\n", 472 | " ```django\n", 473 | " {% block html_attribs %} lang=\"zh-CN\"{% endblock %}\n", 474 | " ```\n", 475 | "\n", 476 | "\n", 477 | "## 自定义错误页面\n", 478 | "\n", 479 | "Flask允许程序使用基于模板的自定义错误页面。 最常见的错误代码有两个:\n", 480 | "\n", 481 | "- **404**: 客户端请求未知页面或路由时显示\n", 482 | "- **500**: 有未处理的异常时显示\n", 483 | "\n", 484 | "`hello.py`自定义错误页面:\n", 485 | "\n", 486 | "```python\n", 487 | "@app.errorhandler(404)\n", 488 | "def page_not_found(e):\n", 489 | " return render_template('404.html'), 404\n", 490 | "# equivalent to\n", 491 | "# app.register_error_handler(404, page_not_found)\n", 492 | "\n", 493 | "@app.errorhandler(500)\n", 494 | "def internal_server_error(e):\n", 495 | " return render_template('500.html'), 500\n", 496 | "```\n", 497 | "\n", 498 | "编写错误处理程序中引用的模板:\n", 499 | "\n", 500 | "```django\n", 501 | "{# templates/404.html #}\n", 502 | "\n", 503 | "{% extends 'base.html' %}\n", 504 | "\n", 505 | "{% block title %}\n", 506 | " Flaskr - Page Not Found\n", 507 | "{% endblock %}\n", 508 | "\n", 509 | "{% block page_content %}\n", 510 | "\t<div class=\"page-header\">\n", 511 | " <h1>Not Found</h1>\n", 512 | " </div>\n", 513 | "{% endblock %}\n", 514 | "```\n", 515 | "\n", 516 | "```django\n", 517 | "{# templates/500.html #}\n", 518 | "\n", 519 | "{% extends 'base.html' %}\n", 520 | "\n", 521 | "{% block title %}\n", 522 | "\tFlaskr - Internal Server Error\n", 523 | "{% endblock %}\n", 524 | "\n", 525 | "{% block page_conent %}\n", 526 | "\t<div class=\"page-header\">\n", 527 | " <h1>Internal Server Error</h1>\n", 528 | " </div>\n", 529 | "{% endblock %}\n", 530 | "```\n", 531 | "\n", 532 | "**🔖 执行`git checkout 2c`签出程序的这个版本。**\n", 533 | "\n", 534 | "\n", 535 | "## 链接\n", 536 | "\n", 537 | "**任何具有多个路由的程序都需要可以连接不同页面的链接。**\n", 538 | "\n", 539 | "在 Python shell 中检查为`hello.py`生成的映射:\n", 540 | "\n", 541 | "```python\n", 542 | "(flaskr_env3) $ python\n", 543 | ">>> from hello import app\n", 544 | ">>> app.url_map\n", 545 | "Map([<Rule '/' (OPTIONS, GET, HEAD) -> index>,\n", 546 | " <Rule '/static/<filename>' (OPTIONS, GET, HEAD) -> static>,\n", 547 | " <Rule '/user/<name>' (OPTIONS, GET, HEAD) -> user>])\n", 548 | "```\n", 549 | "\n", 550 | "在模板中直接编写URL会对代码中定义的路由产生不必要的依赖关系。 如果重新定义路由,模板中的链接可能会失效。\n", 551 | "\n", 552 | "Flask 提供了`url_for()`辅助函数,它可以 **使用程序URL映射中保存的信息生成URL** 。\n", 553 | "\n", 554 | "`url_for()`函数最简单的用法是以视图函数名(或者`app.add_url_route()`定义路由时使用的端点名)作为参数,返回对应的URL。\n", 555 | "\n", 556 | "例如,在当前版本的`hello.py`程序中调用`url_for('index')`得到的结果是`/`。 调用`url_for('index', _external=True)`返回的则是绝对地址,在这个示例中是`http://localhost:5000/`。<sup><a id=\"fnr.2\" class=\"footref\" href=\"#fn.2\">2</a></sup>\n", 557 | "\n", 558 | "使用`url_for()`生成动态地址时,将动态部分作为关键字参数传入。 例如, `url_for('user', name='john', _external=True)` 的返回结果是`http://localhost:5000/user/john`。\n", 559 | "\n", 560 | "传入`url_for()`的关键字参数不仅限于动态路由中的参数。 函数能将任何额外参数添加到查询字符串中。 例如, `url_for('index', page=2)` 的返回结果是`/?page=2`。\n", 561 | "\n", 562 | "\n", 563 | "## 静态文件\n", 564 | "\n", 565 | "默认设置下,Flask在程序根目录中名为`static`的子目录中寻找静态文件。 如果需要,可在`static`文件夹中使用子文件夹存放文件。\n", 566 | "\n", 567 | "URL 映射中有一个`static`路由。对静态文件的引用被当成一个特殊的路由, 即`/static/<filename>`。 调用 `url_for('static', filename='css/styles.css', _external=True)` 得到的结果是`http://localhost:5000/static/css/styles.css`。\n", 568 | "\n", 569 | "下面的例子展示了如何在程序的基模板中放置`favicon.ico`图标。 这个图标会显示在浏览器的地址栏中。\n", 570 | "\n", 571 | "```django\n", 572 | "{% block head %}\n", 573 | "\t{{ super() }}\n", 574 | " <link rel=\"shortcut icon\" href=\"{{ url_for('static', filename='favicon.ico') }}\"\n", 575 | " type=\"image/x-icon\">\n", 576 | " <link rel=\"icon\" href=\"{{ url_for('static', filename='favicon.ico') }}\"\n", 577 | " type=\"image/x-icon\">\n", 578 | "{% endblock %}\n", 579 | "```\n", 580 | "\n", 581 | "图标的声明会插入`head`块的末尾。注意如何使用`super()`保留基模板中定义的块的原始内容。\n", 582 | "\n", 583 | "**🔖 执行`git checkout 2d`签出程序的这个版本。**\n", 584 | "\n", 585 | "## 脚注\n", 586 | "\n", 587 | "<sup><a id=\"fn.1\" class=\"footnum\" href=\"#fnr.1\">1</a></sup> 完整的过滤器列表可在 Jinja2 文档( <http://jinja.pocoo.org/docs/templates/#builtin-filters> )中查看。\n", 588 | "\n", 589 | "<sup><a id=\"fn.2\" class=\"footnum\" href=\"#fnr.2\">2</a></sup> 生成连接程序内不同路由的链接时,使用相对地址就足够了。如果要生成在浏览器之外使用的链接,则必须使用绝对地址,例如在电子邮件中发送的链接。\n" 590 | ] 591 | } 592 | ], 593 | "metadata": { 594 | "kernelspec": { 595 | "display_name": "Python 3", 596 | "name": "python3" 597 | }, 598 | "name": "3-jinja2-template.ipynb" 599 | }, 600 | "nbformat": 4, 601 | "nbformat_minor": 2 602 | } 603 | -------------------------------------------------------------------------------- /ch04/4-web-forms.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": { 6 | "ein.tags": "worksheet-0", 7 | "slideshow": { 8 | "slide_type": "-" 9 | } 10 | }, 11 | "source": [ 12 | "# Web表单处理\n", 13 | "**请求对象** 包含客户端发出的所有请求信息。其中,`request.form`能获取 **POST** 请求中提交的表单数据。\n", 14 | "有些Web表单处理任务比较单调,比如生成表单的HTML代码和验证提交的表单数据。\n", 15 | "\n", 16 | "[Flask-WTF](https://flask-wtf.readthedocs.io/)扩展可以提供非常方便的Web表单处理体验。这个扩展对独立的 [WTForms](http://wtforms.simplecodes.com) 包进行了包装,方便集成到 Flask 程序中。\n", 17 | "\n", 18 | "使用 pip 安装 Flask-WTF 及其依赖:\n", 19 | "\n", 20 | "```sh\n", 21 | "(flaskr_env3) $ pip install flask-wtf\n", 22 | "```\n", 23 | "\n", 24 | "\n", 25 | "## 跨站请求伪造保护\n", 26 | "\n", 27 | "默认情况下,Flask-WTF 能保护所有使用`FlaskForm`处理表单的请求免受跨站请求伪造(Cross-Site Request Forgery, CSRF)的攻击<sup><a id=\"fnr.1\" class=\"footref\" href=\"#fn.1\">1</a></sup>。\n", 28 | "\n", 29 | "为了实现 CSRF 保护,Flask-WTF 需要程序设置一个密钥。Flask-WTF 使用这个密钥生成加密令牌,再用令牌验证请求中表单数据的真伪。\n", 30 | "\n", 31 | "设置密钥的方法如下:\n", 32 | "\n", 33 | "```python\n", 34 | "# hello.py\n", 35 | "\n", 36 | "app = Flask(__name__)\n", 37 | "app.config['SECRET_KEY'] = 'hard to guess string'\n", 38 | "```\n" 39 | ] 40 | }, 41 | { 42 | "cell_type": "markdown", 43 | "metadata": { 44 | "ein.tags": "worksheet-0", 45 | "slideshow": { 46 | "slide_type": "-" 47 | } 48 | }, 49 | "source": [ 50 | "**一种生成密钥的方法** :" 51 | ] 52 | }, 53 | { 54 | "cell_type": "code", 55 | "execution_count": null, 56 | "metadata": { 57 | "autoscroll": false, 58 | "collapsed": false, 59 | "ein.tags": "worksheet-0", 60 | "slideshow": { 61 | "slide_type": "-" 62 | } 63 | }, 64 | "outputs": [], 65 | "source": [ 66 | "import os\n", 67 | "import binascii\n", 68 | "print(binascii.hexlify(os.urandom(24)))" 69 | ] 70 | }, 71 | { 72 | "cell_type": "markdown", 73 | "metadata": { 74 | "ein.tags": "worksheet-0", 75 | "slideshow": { 76 | "slide_type": "-" 77 | } 78 | }, 79 | "source": [ 80 | "`app.config`字典可用来存储框架、扩展和程序本身的配置变量。使用标准的字典句法就能把配置值添加到`app.config`对象中。这个对象还提供了一些方法,可以从文件或环境中导入配置值。\n", 81 | "\n", 82 | "`SECRET_KEY`配置变量是通用密钥,可在 Flask 和多个第三方扩展中使用。如其名所示,加密的强度取决于变量值的机密程度。不同的程序要使用不同的密钥,而且要保证其他人不知道你所用的字符串。\n", 83 | "\n", 84 | "**注意:** 为了增强安全性,密钥不应该直接写入代码,而要保存在环境变量 中。<sup><a id=\"fnr.2\" class=\"footref\" href=\"#fn.2\">2</a></sup>\n", 85 | "\n", 86 | "**注意:** 如果有不使用`FlaskForm`的视图或者提交Ajax请求,需使用Flask-WTF提供 的 **CSRF扩展** 来对这些请求进行保护。详细的内容可参考[Flask-WTF官方文档](https://flask-wtf.readthedocs.io/en/stable/csrf.html#csrf-protection)。\n", 87 | "\n", 88 | "\n", 89 | "## 表单类\n", 90 | "\n", 91 | "使用 Flask-WTF 时,每个 Web 表单都由一个继承自`FlaskForm`的类表示。这个类定义表单中的一组字段,每个字段都用对象表示。字段对象可附属一个或多个验证函数。验证函数用来验证用户提交的输入值是否符合要求。\n", 92 | "\n", 93 | "```python\n", 94 | "from flask_wtf import FlaskForm\n", 95 | "from wtforms import StringField, SubmitField\n", 96 | "from wtforms.validators import Required\n", 97 | "\n", 98 | "\n", 99 | "class NameForm(FlaskForm):\n", 100 | " name = StringField('What is your name?', validators=[Required()])\n", 101 | " submit = SubmitField('Submit')\n", 102 | "```\n", 103 | "\n", 104 | "这个表单中的字段都定义为类变量,类变量的值是相应字段类型的对象。其中,`NameForm`表单中有一个名为`name`的文本字段和一个名为`submit`的提交按钮。`StringField`类表示属性为 `type=\"text\"` 的`<input>`元素。`SubmitField`类表示属性为 `type=\"submit\"` 的`<input>`元素。字段构造函数的第一个参数是把表单渲染成 HTML 时使用的标号。\n", 105 | "\n", 106 | "`StringField`构造函数中的可选参数`validators`指定一个由验证函数组成的列表, 在接受用户提交的数据之前验证数据。验证函数`Required()`确保提交的字段不为空。\n", 107 | "\n", 108 | "WTForms 支持的HTML标准字段如下表所示:\n", 109 | "\n", 110 | "| 字段类型 | 说明 |\n", 111 | "|-----------------------|--------------------------------|\n", 112 | "| `StringField` | 文本字段 |\n", 113 | "| `TextAreaField` | 多行文本字段 |\n", 114 | "| `PasswordField` | 密码文本字段 |\n", 115 | "| `HiddenField` | 隐藏文本字段 |\n", 116 | "| `DateField` | 文本字段,值为 `datetime.date` 格式 |\n", 117 | "| `DateTimeField` | 文本字段,值为 `datetime.datetime` 格式 |\n", 118 | "| `IntegerField` | 文本字段,值为整数 |\n", 119 | "| `DecimalField` | 文本字段,值为 `decimal.Decimal` |\n", 120 | "| `FloatField` | 文本字段,值为浮点数 |\n", 121 | "| `BooleanField` | 复选框,值为 `True` 和 `False` |\n", 122 | "| `RadioField` | 一组单选框 |\n", 123 | "| `SelectField` | 下拉列表 |\n", 124 | "| `SelectMultipleField` | 下拉列表,可选择多个值 |\n", 125 | "| `FileField` | 文件上传字段 |\n", 126 | "| `SubmitField` | 表单提交按钮 |\n", 127 | "| `FormField` | 把表单作为字段嵌入另一个表单 |\n", 128 | "| `FieldList` | 一组指定类型的字段 |\n", 129 | "\n", 130 | "WTForms 内建的验证函数如下表所示:\n", 131 | "\n", 132 | "| 验证函数 | 说明 |\n", 133 | "|-----------------|-------------------------------|\n", 134 | "| `Email` | 验证电子邮件地址 |\n", 135 | "| `EqualTo` | 比较两个字段的值;常用于要求输入两次密码进行确认的情况 |\n", 136 | "| `IPAddress` | 验证网络地址(默认验证IPv4类型,IPv6类型可选) |\n", 137 | "| `MacAddress` | 验证Mac地址 |\n", 138 | "| `Length` | 验证输入字符串的长度 |\n", 139 | "| `NumberRange` | 验证输入的值在数字范围内 |\n", 140 | "| `Optional` | 无输入值时跳过其他验证函数 |\n", 141 | "| `DataRequired` | 确保字段中有数据 |\n", 142 | "| `Required` | 等价于 `DataRequired` ,将在v3.0中移除 |\n", 143 | "| `InputRequired` | 确保字段中有输入 |\n", 144 | "| `Regexp` | 使用正则表达式验证输入值 |\n", 145 | "| `URL` | 验证 URL |\n", 146 | "| `UUID` | 验证UUID |\n", 147 | "| `AnyOf` | 确保输入值在可选值列表中 |\n", 148 | "| `NoneOf` | 确保输入值不在可选值列表中 |\n", 149 | "\n", 150 | "\n", 151 | "## 把表单渲染为HTML\n", 152 | "\n", 153 | "表单字段是可调用的,在模板中调用后会渲染成 HTML。假设视图函数把一个`NameForm`实例通过参数`form`传入模板, 在模板中可以生成一个简单的表单。\n", 154 | "\n", 155 | "```django\n", 156 | "<form method=\"POST\">\n", 157 | " {{ form.hidden_tag() }}\n", 158 | " {{ form.name.label }} {{ form.name() }}\n", 159 | " {{ form.submit() }}\n", 160 | "</form>\n", 161 | "```\n", 162 | "\n", 163 | "如果想改进表单的外观,可以把参数传入渲染字段的函数,传入的参数会被转换成字段的 HTML 属性。例如,可以为字段指定`id`或`class`属性,然后定义 CSS 样式:\n", 164 | "\n", 165 | "```django\n", 166 | "<form method=\"POST\">\n", 167 | " {{ form.hidden_tag() }}\n", 168 | " {{ form.name.label }} {{ form.name(id='my-text-field') }}\n", 169 | " {{ form.submit() }}\n", 170 | "</form>\n", 171 | "```\n", 172 | "\n", 173 | "\n", 174 | "### 使用 Bootstrap 中的表单样式\n", 175 | "\n", 176 | "**Flask-Bootstrap** 提供了一个非常高端的辅助函数,可以使用 Bootstrap 中 预先定义好的表单样式渲染整个表单。\n", 177 | "\n", 178 | "```django\n", 179 | "{% import \"bootstrap/wtf.html\" as wtf %}\n", 180 | "{{ wtf.quick_form(form) }}\n", 181 | "```\n", 182 | "\n", 183 | "导入的`bootstrap/wtf.html`文件中定义了一个使用 Bootstrap 渲染 Flask-WTF 表单对象的辅助函数。`wtf.quick_form()`函数的参数为 Flask-WTF 表单对象, 使用 Bootstrap 的默认样式渲染传入的表单。\n", 184 | "\n", 185 | "```django\n", 186 | "{# templates/index.html #}\n", 187 | "\n", 188 | "{% extends \"base.html\" %}\n", 189 | "{% import \"bootstrap/wtf.html\" as wtf %}\n", 190 | "\n", 191 | "{% block page_content %}\n", 192 | " <div class=\"page-header\">\n", 193 | " <h1>Hello, {% if name %}{{ name }}{% else %}Stranger{% endif %}!</h1>\n", 194 | " </div>\n", 195 | " {{ wtf.quick_form(form) }}\n", 196 | "{% endblock %}\n", 197 | "```\n", 198 | "\n", 199 | "\n", 200 | "## 在视图函数中处理表单\n", 201 | "\n", 202 | "在新的`hello.py`中,视图函数`index()`不仅要渲染表单,还要接收表单中的数据。\n", 203 | "\n", 204 | "```python\n", 205 | "@app.route('/', methods=['GET', 'POST'])\n", 206 | "def index():\n", 207 | " name = None\n", 208 | " form = NameForm()\n", 209 | " if form.validate_on_submit():\n", 210 | " name = form.name.data\n", 211 | " form.name.data = ''\n", 212 | " return render_template('index.html', form=form, name=name)\n", 213 | "```\n", 214 | "\n", 215 | "`app.route`装饰器中添加的`methods`参数告诉 Flask 在 URL 映射中把这个视图函数注册 为 GET 和 POST 请求的处理程序。如果没指定`methods`参数,就只把视图函数注册为 GET 请求的处理程序。\n", 216 | "\n", 217 | "把 POST 加入方法列表很有必要,因为将提交表单作为 POST 请求进行处理更加便利。表单也可作为 GET 请求提交,不过 GET 请求没有主体,提交的数据以查询字符串的形式附加到 URL 中,可在浏览器的地址栏中看到。基于这个以及其他多个原因,提交表单大都作为 POST 请求进行处理。\n", 218 | "\n", 219 | "局部变量`name`用来存放表单中输入的有效名字,如果没有输入,其值为`None`。如上述代码所示,在视图函数中创建一个`NameForm`类实例用于表示表单。提交表单后,如果数据能被所有验证函数接受,那么`validate_on_submit()`方法的返回值 为`True`,否则返回`False`。这个函数的返回值决定是重新渲染表单还是处理表单提交的数据。\n", 220 | "\n", 221 | "用户第一次访问程序时,服务器会收到一个没有表单数据的 GET 请求, 所以`validate_on_ submit()`将返回`False`。`if`语句的内容将被跳过,通过渲染模板处理请求,并传入表单对象和值为`None`的`name`变量作为参数。用户会看到浏览器中显示了一个表单。\n", 222 | "\n", 223 | "用户提交表单后,服务器收到一个包含数据的`POST`请求。`validate_on_submit()`会调用`name`字段上附属的`Required()`验证函数。如果名字不为空,就能通过验证,`validate_on_submit()`返回`True`。现在,用户输入的名字可通过字段的`data`属性获取。在`if`语句中,把名字赋值给局部变量`name`,然后再把`data`属性设为空字符串,从而清空表单字段。最后一行调用`render_template()`函数渲染模板,但这一次参数`name`的值为表单中 输入的名字,因此会显示一个针对该用户的欢迎消息。\n", 224 | "\n", 225 | "如果用户提交表单之前没有输入名字,`Required()`验证函数会捕获这个错误。\n", 226 | "\n", 227 | "**🔖 执行`git checkout 3a`签出程序的这个版本。**\n", 228 | "\n", 229 | "\n", 230 | "## 重定向和用户会话\n", 231 | "\n", 232 | "刷新页面时浏览器会重新发送之前已经发送过的最后一个请求。如果这个请求是一个包含表单数据的 POST 请求,刷新页面后会再次提交表单。大多数情况下,这并不是理想的处理方式。\n", 233 | "\n", 234 | "基于这个原因, **最好使用重定向作为 POST 请求的响应,而不是使用常规响应。**\n", 235 | "\n", 236 | "**重定向** 是一种特殊的响应,响应内容是 URL,而不是包含 HTML 代码的字符串。浏览器收到这种响应时,会向重定向的 URL 发起 GET 请求,显示页面的内容。这个页面的加载可能要多花几微秒,因为要先把第二个请求发给服务器。除此之外,用户不会察觉到有什么不同。现在,最后一个请求是 GET 请求,所以刷新命令就能像预期的那样正常使用。这个技巧称为 **Post/重定向/Get** 模式。\n", 237 | "\n", 238 | "另外一个问题,程序处理 POST 请求时,使用`form.name.data`获取用户输入的名字, 可是一旦这个请求结束,数据也就丢失了。因为这个 POST 请求使用重定向处理, 所以程序需要保存输入的名字,这样重定向后的请求才能获得并使用这个名字,从而 构建真正的响应。\n", 239 | "\n", 240 | "程序可以把数据存储在用户会话中,在请求之间“记住”数据。 **用户会话** 是一种私有存储,存在于每个连接到服务器的客户端中。<sup><a id=\"fnr.3\" class=\"footref\" href=\"#fn.3\">3</a></sup>\n", 241 | "\n", 242 | "```python\n", 243 | "from flask import Flask, render_template, session, redirect, url_for\n", 244 | "\n", 245 | "@app.route('/', methods=['GET', 'POST'])\n", 246 | "def index():\n", 247 | " form = NameForm()\n", 248 | " if form.validate_on_submit():\n", 249 | " session['name'] = form.name.data\n", 250 | " return redirect(url_for('index'))\n", 251 | " return render_template('index.html', form=form, name=session.get('name'))\n", 252 | "```\n", 253 | "\n", 254 | "在程序的前一个版本中,局部变量`name`被用于存储用户在表单中输入的名字。这个变量现在保存在用户会话中,即`session['name']`,所以在两次请求之间也能记住输入的值。\n", 255 | "\n", 256 | "现在,包含合法表单数据的请求最后会调用`redirect()`函数。`redirect()`是个辅助函数,用来生成 HTTP 重定向响应,其参数是重定向的 URL, 这里使用的重定向 URL 是程序的根地址,因此重定向响应本可以写得更简单一些, 写成`redirect('/')`,但却会使用 Flask 提供的 URL 生成函数`url_for()`。推荐使用`url_for()`生成 URL,因为这个函数使用 URL 映射生成 URL, 从而保证 URL 和定义的路由兼容,而且修改路由名字后依然可用。\n", 257 | "\n", 258 | "`url_for()`函数的第一个且唯一必须指定的参数是端点名,即路由的内部名字。默认情况下,路由的端点是相应视图函数的名字。在这个示例中,处理根地址的视图函数是`index()`,因此传给`url_for()`函数的名字 是`index`。\n", 259 | "\n", 260 | "最后一处改动位于`render_function()`函数中,使用`session.get('name')`直接从会话中读 取`name`参数的值。和普通的字典一样,这里使用`get()`获取字典中键对应的值以避免未找 到键的异常情况,因为对于不存在的键,`get()`会返回默认值`None`。\n", 261 | "\n", 262 | "**🔖 执行`git checkout 3b`签出程序的这个版本。**\n", 263 | "\n", 264 | "\n", 265 | "## Flash消息\n", 266 | "\n", 267 | "请求完成后,有时需要让用户知道状态发生了变化。这里可以使用确认消息、警告或者错误提醒。一个典型例子是,用户提交了有一项错误的登录表单后,服务器发回的响应重新渲染了登录表单, 并在表单上面显示一个消息,提示用户用户名或密码错误。\n", 268 | "\n", 269 | "`flash()`函数可实现这种效果:\n", 270 | "\n", 271 | "```python\n", 272 | "from flask import Flask, render_template, session, redirect, url_for, flash\n", 273 | "\n", 274 | "@app.route('/', methods=['GET', 'POST'])\n", 275 | "def index():\n", 276 | " form = NameForm()\n", 277 | " if form.validate_on_submit():\n", 278 | " old_name = session.get('name')\n", 279 | " if old_name is not None and old_name != form.name.data:\n", 280 | " flash('Looks like you have changed your name!')\n", 281 | " session['name'] = form.name.data\n", 282 | " return redirect(url_for('index'))\n", 283 | " return render_template('index.html', form=form, name=session.get('name'))\n", 284 | "```\n", 285 | "\n", 286 | "每次提交的名字都会和存储在用户会话中的名字进行比较,而会话中存储的名字是前一次在这个表单中提交的数据。如果两个名字不一样,就会调用`flash()`函数, 在发给客户端的下一个响应中显示一个消息。\n", 287 | "\n", 288 | "仅调用`flash()`函数并不能把消息显示出来,程序使用的模板要渲染这些消息。最好在基模板中渲染 Flash 消息,因为这样所有页面都能使用这些消息。Flask 把`get_flashed_messages()`函数开放给模板,用来获取并渲染消息。\n", 289 | "\n", 290 | "```django\n", 291 | "{% block content %}\n", 292 | " <div class=\"container\">\n", 293 | " {% for message in get_flashed_messages() %}\n", 294 | "\t <div class=\"alert alert-warning\">\n", 295 | " <button type=\"button\" class=\"close\" data-dismiss=\"alert\">×</button>\n", 296 | " {{ message }}\n", 297 | " </div>\n", 298 | " {% endfor %}\n", 299 | " {% block page_content %}{% endblock %}\n", 300 | " </div>\n", 301 | "{% endblock %}\n", 302 | "```\n", 303 | "\n", 304 | "在模板中使用循环是因为在之前的请求循环中每次调用`flash()`函数时都会生成一个消息, 所以可能有多个消息在排队等待显示。`get_flashed_messages()`函数获取的消息在下次调 用时不会再次返回,因此 Flash 消息只显示一次,然后就消失了。\n", 305 | "\n", 306 | "**🔖 执行`git checkout 3c`签出程序的这个版本。**\n", 307 | "\n", 308 | "## 脚注\n", 309 | "\n", 310 | "<sup><a id=\"fn.1\" class=\"footnum\" href=\"#fnr.1\">1</a></sup> 恶意网站把请求发送到被攻击者已登录的其他网站时就会引发 CSRF 攻击。\n", 311 | "\n", 312 | "<sup><a id=\"fn.2\" class=\"footnum\" href=\"#fnr.2\">2</a></sup> 后面章节会学习。\n", 313 | "\n", 314 | "<sup><a id=\"fn.3\" class=\"footnum\" href=\"#fnr.3\">3</a></sup> 默认情况下,\n", 315 | "用户会话保存在客户端 cookie 中,使用设置的 `SECRET_KEY` 进行加密签名。\n", 316 | "如果篡改了 cookie 中的内容,签名就会失效,会话也会随之失效。" 317 | ] 318 | } 319 | ], 320 | "metadata": { 321 | "kernelspec": { 322 | "display_name": "Python 3", 323 | "name": "python3" 324 | }, 325 | "name": "4-web-form.ipynb" 326 | }, 327 | "nbformat": 4, 328 | "nbformat_minor": 2 329 | } 330 | -------------------------------------------------------------------------------- /ch05/5-peewee-orm.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": { 6 | "ein.tags": "worksheet-0", 7 | "slideshow": { 8 | "slide_type": "-" 9 | } 10 | }, 11 | "source": [ 12 | "# 使用ORM进行数据库操作\n", 13 | "\n", 14 | "Web 程序最常用基于 **关系模型** 的数据库,这种数据库也称为 **SQL** 数据库, 因为它们使用结构化查询语言。\n", 15 | "\n", 16 | "**文档数据库** 和 **键值对数据库** ,这两种数据库合称 **NoSQL** 数据库。\n", 17 | "\n", 18 | "\n", 19 | "## Python数据库框架\n", 20 | "\n", 21 | "大多数的数据库引擎都有对应的 Python 包,包括开源包和商业包。还有一些数据库抽象层代码包供选择,比如SQLAlchemy,Peewee和MongoEngine等, 可以使用这些抽象包直接处理高等级的 Python 对象,而不用处理如表、文档或查询语言 此类的数据库实体。\n", 22 | "\n", 23 | "选择数据库框架时,要考虑的因素:\n", 24 | "\n", 25 | "- 易用性\n", 26 | "\n", 27 | " 如果直接比较数据库引擎和数据库抽象层,显然后者取胜。抽象层,也称为对象关系映射(Object-Relational Mapper,ORM)或 对象文档映射(Object-Document Mapper,ODM),在用户不知觉的情况下把高层的 面向对象操作转换成低层的数据库指令。\n", 28 | "\n", 29 | "- 性能\n", 30 | "\n", 31 | " ORM 和 ODM 把对象业务转换成数据库业务会有一定的损耗。大多数情况下,这种性能的降低微不足道,但也不一定都是如此。一般情况下,ORM 和 ODM 对生产率的提升远远超过了这一丁点儿的性能降低, 所以性能降低这个理由不足以说服用户完全放弃 ORM 和 ODM。 **真正的关键点在于如何选择一个能直接操作低层数据库的抽象层,以防特定的操作需要直接使用数据库原生指令优化。**\n", 32 | "\n", 33 | "- 可移植性\n", 34 | "\n", 35 | " 选择数据库时,必须考虑其是否能在你的开发平台和生产平台中使用。例如,如果你打 算利用云平台托管程序,就要知道这个云服务提供了哪些数据库可供选择。\n", 36 | "\n", 37 | " 可移植性还针对 ORM 和 ODM。尽管有些框架只为一种数据库引擎提供抽象层,但其 他框架可能做了更高层的抽象,它们支持不同的数据库引擎, 而且都使用相同的面向对象接口。\n", 38 | "\n", 39 | "- Flask 集成度\n", 40 | "\n", 41 | " 选择框架时,不一定非得选择已经集成了 Flask 的框架,但选择这些框架可以节省 编写集成代码的时间。使用集成了 Flask 的框架可以简化配置和操作。\n", 42 | "\n", 43 | "基于以上因素,我们最终选择 [Peewee](http://docs.peewee-orm.com/) 。\n", 44 | "\n", 45 | "\n", 46 | "## 在Flask中使用Peewee\n", 47 | "\n", 48 | "\n", 49 | "### 定义模型\n", 50 | "\n", 51 | "在`hello.py`中定义Role和User模型:\n", 52 | "\n", 53 | "```python\n", 54 | "import peewee as pw\n", 55 | "\n", 56 | "\n", 57 | "db = pw.SqliteDatabase(\"flaskr.db\")\n", 58 | "\n", 59 | "\n", 60 | "class BaseModel(pw.Model):\n", 61 | " class Meta:\n", 62 | " database = db\n", 63 | "\n", 64 | "\n", 65 | "class Role(BaseModel):\n", 66 | " name = pw.CharField(64, unique=True)\n", 67 | "\n", 68 | " def __repr__(self):\n", 69 | " return '<Role %r>' % self.name\n", 70 | "\n", 71 | " class Meta:\n", 72 | " db_table = 'roles'\n", 73 | "\n", 74 | "\n", 75 | "class User(BaseModel):\n", 76 | " username = pw.CharField(64, unique=True, index=True)\n", 77 | "\n", 78 | " def __repr__(self):\n", 79 | " return '<User %r>' % self.username\n", 80 | "\n", 81 | " class Meta:\n", 82 | " db_table = 'users'\n", 83 | "```\n", 84 | "\n", 85 | "下表列出了一些可用的字段类型以及对应的数据库字段类型:\n", 86 | "\n", 87 | "| 字段类型 | Sqlite | Postgresql | MySQL |\n", 88 | "|---------------------|----------|------------------|------------------|\n", 89 | "| `CharField` | varchar | varchar | varchar |\n", 90 | "| `FixedCharField` | char | char | char |\n", 91 | "| `TextField` | text | text | longtext |\n", 92 | "| `DateTimeField` | datetime | timestamp | datetime |\n", 93 | "| `IntegerField` | integer | integer | integer |\n", 94 | "| `BooleanField` | integer | boolean | bool |\n", 95 | "| `FloatField` | real | real | real |\n", 96 | "| `DoubleField` | real | double precision | double precision |\n", 97 | "| `BigIntegerField` | integer | bigint | bigint |\n", 98 | "| `SmallIntegerField` | integer | smallint | smallint |\n", 99 | "| `DecimalField` | decimal | numeric | numeric |\n", 100 | "| `PrimaryKeyField` | integer | serial | integer |\n", 101 | "| `ForeignKeyField` | integer | integer | integer |\n", 102 | "| `DateField` | date | date | date |\n", 103 | "| `TimeField` | time | time | time |\n", 104 | "| `TimestampField` | integer | integer | integer |\n", 105 | "| `BlobField` | blob | bytea | blob |\n", 106 | "| `UUIDField` | text | uuid | varchar(40) |\n", 107 | "| `BareField` | untyped | not supported | not supported |\n", 108 | "\n", 109 | "字段初始化参数及默认值:\n", 110 | "\n", 111 | "- `null = False` – 布尔值,是否允许储存`null`值。\n", 112 | "- `index = False` – 布尔值,是否为这一列添加索引。\n", 113 | "- `unique = False` – 布尔值,是否为这一列添加唯一性索引。另外可参考如何添加复合索引。\n", 114 | "- `verbose_name = None` – 字符串,为模型字段添加用户友好的自定义标注。\n", 115 | "- `help_text = None` – 字符串,为此字段添加帮助文本信息。\n", 116 | "- `db_column = None` – 字符串,用于底层存储的列名,有助于遗留数据库的兼容性。\n", 117 | "- `default = None` – 任意类型,用于初始化的默认值,如果是可调用对象,则调用生成相应的值。\n", 118 | "- `choices = None` – 可迭代的二元元组,对应`value`和`display`。\n", 119 | "- `primary_key = False` – 布尔值,是否时此数据表的主键。\n", 120 | "- `sequence = None` – 字符串,序列名字,如果后端数据库支持的话。\n", 121 | "- `constraints = None` - 一个或多个约束条件列表,例如`[Check('price > 0')]`。\n", 122 | "- `schema = None` – 字符串,schema的可选名字,如果后端数据库支持的话。\n", 123 | "\n", 124 | "一些列类型接收特定的参数:\n", 125 | "\n", 126 | "| 字段类型 | 特殊参数 |\n", 127 | "|-------------------|----------------------------------------------------------------------------|\n", 128 | "| `CharField` | `max_length` |\n", 129 | "| `FixedCharField` | `max_length` |\n", 130 | "| `DateTimeField` | `formats` |\n", 131 | "| `DateField` | `formats` |\n", 132 | "| `TimeField` | `formats` |\n", 133 | "| `TimestampField` | `resolution`, `utc` |\n", 134 | "| `DecimalField` | `max_digits`, `decimal_places`, `auto_round`, `rounding` |\n", 135 | "| `ForeignKeyField` | `rel_model`, `related_name`, `to_field`, `on_delete`, `on_update`, `extra` |\n", 136 | "| `BareField` | `coerce` |\n", 137 | "\n", 138 | "虽然没有强制要求,但这两个模型都定义了`__repr()__`方法,返回一个具有可读性的字符 串表示模型,可在调试和测试时使用。\n", 139 | "\n", 140 | "\n", 141 | "### 关系\n", 142 | "\n", 143 | "关系型数据库使用关系把不同表中的行联系起来。\n", 144 | "\n", 145 | "角色到用户是 **一对多** 关系,因为一个角色可属于多个用户,而每个用户都只能有一个角色。\n", 146 | "\n", 147 | "```python\n", 148 | "class User(BaseModel):\n", 149 | " # ...\n", 150 | " role = pw.ForeignKeyField(Role, related_name='users', null=True)\n", 151 | "```\n", 152 | "\n", 153 | "\n", 154 | "### 数据库操作\n", 155 | "\n", 156 | "学习如何使用模型的最好方法是在 Python shell 中实际操作。\n", 157 | "\n", 158 | "\n", 159 | "#### 创建表\n", 160 | "\n", 161 | "首先,要根据模型类来创建数据库。\n", 162 | "\n", 163 | "定义一个函数`create_tables`,用来创建数据表。\n", 164 | "\n", 165 | "```python\n", 166 | "def create_tables():\n", 167 | " db.connect()\n", 168 | " db.create_tables([Role, User])\n", 169 | "```\n", 170 | "\n", 171 | "接下来在开启Flask shell 并进行实际操作:\n", 172 | "\n", 173 | "```sh\n", 174 | "(flaskr_env3) $ export FLASK_APP=hello.py\n", 175 | "(flaskr_env3) $ export FLASK_DEBUG=1\n", 176 | "(flaskr_env3) $ flask shell\n", 177 | "```\n", 178 | "\n", 179 | " ...\n", 180 | " App: hello [debug]\n", 181 | " Instance: ...\n", 182 | "\n", 183 | "```python\n", 184 | ">>> from hello import create_tables\n", 185 | ">>> create_tables()\n", 186 | "```\n", 187 | "\n", 188 | "\n", 189 | "#### 插入行\n", 190 | "\n", 191 | "```python\n", 192 | ">>> from hello import Role, User\n", 193 | ">>> admin_role = Role(name='Admin')\n", 194 | ">>> mod_role = Role(name='Moderator')\n", 195 | ">>> user_role = Role(name='User')\n", 196 | ">>> user_john = User(username='john', role=admin_role)\n", 197 | ">>> user_susan = User(username='susan', role=user_role)\n", 198 | ">>> user_david = User(username='david', role=user_role)\n", 199 | ">>> print(admin_role.id)\n", 200 | "None\n", 201 | ">>> admin_role.save()\n", 202 | ">>> mod_role.save()\n", 203 | ">>> user_role.save()\n", 204 | ">>> user_john.save()\n", 205 | ">>> user_susan.save()\n", 206 | ">>> user_david.save()\n", 207 | "```\n", 208 | "\n", 209 | "再查看`id`属性,现在已经赋值了:\n", 210 | "\n", 211 | "```python\n", 212 | ">>> print(admin_role.id)\n", 213 | "1\n", 214 | ">>> print(mod_role.id)\n", 215 | "2\n", 216 | ">>> print(user_role.id)\n", 217 | "3\n", 218 | "```\n", 219 | "\n", 220 | "\n", 221 | "#### 修改行\n", 222 | "\n", 223 | "```python\n", 224 | ">>> admin_role.name = 'Administrator'\n", 225 | ">>> admin_role.save()\n", 226 | "```\n", 227 | "\n", 228 | "\n", 229 | "#### 删除行\n", 230 | "\n", 231 | "```python\n", 232 | ">>> mod_role.delete_instance()\n", 233 | "1\n", 234 | "```\n", 235 | "\n", 236 | "\n", 237 | "#### 查询行\n", 238 | "\n", 239 | "```python\n", 240 | ">>> list(Role.select())\n", 241 | "[<Role 'Administrator'>, <Role 'User'>]\n", 242 | ">>> list(User.select())\n", 243 | "[<User 'john'>, <User 'susan'>, <User 'david'>]\n", 244 | "```\n", 245 | "\n", 246 | "查找角色为 \"User\" 的所有用户:\n", 247 | "\n", 248 | "```python\n", 249 | ">>> list(User.select().where(User.role==user_role))\n", 250 | "[<User 'susan'>, <User 'david'>]\n", 251 | "```\n", 252 | "\n", 253 | "查看Peewee为查询生成的原生SQL查询语句:\n", 254 | "\n", 255 | "```python\n", 256 | ">>> print(User.select().where(User.role==user_role).sql())\n", 257 | "('SELECT \"t1\".\"id\", \"t1\".\"username\", \"t1\".\"role_id\" FROM \"users\" AS t1 WHERE (\"t1\".\"role_id\" = ?)', [3])\n", 258 | "```\n", 259 | "\n", 260 | "如果你退出了 shell 会话,前面这些例子中创建的对象就不会以 Python 对象的形式存在, 而是作为各自数据库表中的行。如果你打开了一个新的 shell 会话,就要从数据库中读取行, 再重新创建 Python 对象。\n", 261 | "\n", 262 | "```python\n", 263 | ">>> user_role = Role.select().where(Role.name=='User').get()\n", 264 | "```\n", 265 | "\n", 266 | "关系和查询的处理方式类似。下面这个例子分别从关系的两端查询角色和用户之间的一对多关系:\n", 267 | "\n", 268 | "```python\n", 269 | ">>> users = user_role.users\n", 270 | ">>> list(users)\n", 271 | "[<User 'susan'>, <User 'david'>]\n", 272 | ">>> users[0].role\n", 273 | "<Role 'User'>\n", 274 | "```\n", 275 | "\n", 276 | "**🔖 执行`git checkout 4a`签出程序的这个版本。**\n", 277 | "\n", 278 | "\n", 279 | "### 在视图函数中操作数据库\n", 280 | "\n", 281 | "前一节介绍的数据库操作可以直接在视图函数中进行。\n", 282 | "\n", 283 | "下面的代码示例展示了首页路由的新版本,把用户输入的名字写入数据库。\n", 284 | "\n", 285 | "```python\n", 286 | "@app.route('/', methods=['GET', 'POST'])\n", 287 | "def index():\n", 288 | " form = NameForm()\n", 289 | " if form.validate_on_submit():\n", 290 | " user = User.select().where(User.username == form.name.data).first()\n", 291 | " if user is None:\n", 292 | " user = User(username=form.name.data)\n", 293 | " user.save()\n", 294 | " session['known'] = False\n", 295 | " else:\n", 296 | " session['known'] = True\n", 297 | " session['name'] = form.name.data\n", 298 | " form.name.data = ''\n", 299 | " return redirect(url_for('index'))\n", 300 | " return render_template('index.html', form=form,\n", 301 | " name=session.get('name'),\n", 302 | " known=session.get('known', False))\n", 303 | "```\n", 304 | "\n", 305 | "变量`known`被写入用户会话中,因此重定向之后,可以把数据传给模板, 用来显示自定义的欢迎消息。\n", 306 | "\n", 307 | "对应的模板新版本如下所示,这个模板使用`known`参数在欢迎消息中加入了第二行, 从而对已知用户和新用户显示不同的内容。\n", 308 | "\n", 309 | "```django\n", 310 | "{# templates/index.html #}\n", 311 | "\n", 312 | "{% extends \"base.html\" %}\n", 313 | "{% import \"bootstrap/wtf.html\" as wtf %}\n", 314 | "\n", 315 | "{% block page_content %}\n", 316 | " <div class=\"page-header\">\n", 317 | " <h1>Hello, {% if name %}{{ name }}{% else %}Stranger{% endif %}!</h1>\n", 318 | " {% if not known %}\n", 319 | " <p>Pleased to meet you!</p>\n", 320 | " {% else %}\n", 321 | " <p>Happy to see you again!</p>\n", 322 | " {% endif %}\n", 323 | " </div>\n", 324 | " {{ wtf.quick_form(form) }}\n", 325 | "{% endblock %}\n", 326 | "```\n", 327 | "\n", 328 | "**🔖 执行`git checkout 4b`签出程序的这个版本。**\n", 329 | "\n", 330 | "\n", 331 | "## 使用Flask-PW\n", 332 | "\n", 333 | "为了方便在项目中集成Peewee,后面的章节里我们将使用一个扩展 [Flask-PW](https://github.com/klen/flask-pw),其提供了数据库连接配置及一些工具如数据库 迁移和信号,另外,它还为在[Flask-Debugtoolbar](https://flask-debugtoolbar.readthedocs.org/en/latest/) 使用Peewee提供了支持。\n", 334 | "\n", 335 | "\n", 336 | "## 数据库迁移\n", 337 | "\n", 338 | "目前,Peewee尚未支持自动数据库迁移,但是可以使用其提供的`playhouse.migrate`模块来创建 简单的迁移脚本。\n", 339 | "\n", 340 | "> Peewee’s migrations do not handle introspection and database “versioning”. Rather, peewee provides a number of helper functions for generating and running schema-altering statements. This engine provides the basis on which a more sophisticated tool could some day be built.\n", 341 | ">\n", 342 | "> Migrations can be written as simple python scripts and executed from the command-line. Since the migrations only depend on your applications Database object, it should be easy to manage changing your model definitions and maintaining a set of migration scripts without introducing dependencies.\n", 343 | "\n", 344 | "下面是一个例子:\n", 345 | "\n", 346 | "```python\n", 347 | "from playhouse.migrate import SqliteMigrator, migrate\n", 348 | "import peewee as pw\n", 349 | "\n", 350 | "\n", 351 | "my_db = pw.SqliteDatabase('my_database.db')\n", 352 | "migrator = SqliteMigrator(my_db)\n", 353 | "\n", 354 | "title_field = pw.CharField(default='')\n", 355 | "status_field = pw.IntegerField(null=True)\n", 356 | "\n", 357 | "# run migrations inside a transaction\n", 358 | "with my_db.transaction():\n", 359 | " migrate(\n", 360 | " migrator.add_column('some_table', 'title', title_field),\n", 361 | " migrator.add_column('some_table', 'status', status_field),\n", 362 | " migrator.drop_column('some_table', 'old_column'),\n", 363 | " )\n", 364 | "```\n", 365 | "\n", 366 | "**支持的操作:**\n", 367 | "\n", 368 | "- 为已有模型添加新字段\n", 369 | "\n", 370 | " ```python\n", 371 | " # Create your field instances. For non-null fields you must specify a\n", 372 | " # default value.\n", 373 | " pubdate_field = DateTimeField(null=True)\n", 374 | " comment_field = TextField(default='')\n", 375 | "\n", 376 | " # Run the migration, specifying the database table, field name and field.\n", 377 | " migrate(\n", 378 | " migrator.add_column('comment_tbl', 'pub_date', pubdate_field),\n", 379 | " migrator.add_column('comment_tbl', 'comment', comment_field),\n", 380 | " )\n", 381 | " ```\n", 382 | "\n", 383 | "- 重命名字段\n", 384 | "\n", 385 | " ```python\n", 386 | " # Specify the table, original name of the column, and its new name.\n", 387 | " migrate(\n", 388 | " migrator.rename_column('story', 'pub_date', 'publish_date'),\n", 389 | " migrator.rename_column('story', 'mod_date', 'modified_date'),\n", 390 | " )\n", 391 | " ```\n", 392 | "\n", 393 | "- 删除字段\n", 394 | "\n", 395 | " ```python\n", 396 | " migrate(\n", 397 | " migrator.drop_column('story', 'some_old_field'),\n", 398 | " )\n", 399 | " ```\n", 400 | "\n", 401 | "- 设置字段 nullable 或者 not nullable\n", 402 | "\n", 403 | " ```python\n", 404 | " # Note that when making a field not null that field must not have any\n", 405 | " # NULL values present.\n", 406 | " migrate(\n", 407 | " # Make `pub_date` allow NULL values.\n", 408 | " migrator.drop_not_null('story', 'pub_date'),\n", 409 | "\n", 410 | " # Prevent `modified_date` from containing NULL values.\n", 411 | " migrator.add_not_null('story', 'modified_date'),\n", 412 | " )\n", 413 | " ```\n", 414 | "\n", 415 | "- 添加索引\n", 416 | "\n", 417 | " ```python\n", 418 | " # Specify the table, column names, and whether the index should be\n", 419 | " # UNIQUE or not.\n", 420 | " migrate(\n", 421 | " # Create an index on the `pub_date` column.\n", 422 | " migrator.add_index('story', ('pub_date',), False),\n", 423 | "\n", 424 | " # Create a multi-column index on the `pub_date` and `status` fields.\n", 425 | " migrator.add_index('story', ('pub_date', 'status'), False),\n", 426 | "\n", 427 | " # Create a unique index on the category and title fields.\n", 428 | " migrator.add_index('story', ('category_id', 'title'), True),\n", 429 | " )\n", 430 | " ```" 431 | ] 432 | } 433 | ], 434 | "metadata": { 435 | "kernelspec": { 436 | "display_name": "Python 3", 437 | "language": "python", 438 | "name": "python3" 439 | }, 440 | "language_info": { 441 | "codemirror_mode": { 442 | "name": "ipython", 443 | "version": 3 444 | }, 445 | "file_extension": ".py", 446 | "mimetype": "text/x-python", 447 | "name": "python", 448 | "nbconvert_exporter": "python", 449 | "pygments_lexer": "ipython3", 450 | "version": "3.6.2" 451 | }, 452 | "name": "5-peewee-orm.ipynb", 453 | "toc": { 454 | "colors": { 455 | "hover_highlight": "#ddd", 456 | "running_highlight": "#FF0000", 457 | "selected_highlight": "#ccc" 458 | }, 459 | "moveMenuLeft": true, 460 | "nav_menu": { 461 | "height": "264px", 462 | "width": "252px" 463 | }, 464 | "navigate_menu": true, 465 | "number_sections": false, 466 | "sideBar": true, 467 | "threshold": 4, 468 | "toc_cell": false, 469 | "toc_section_display": "block", 470 | "toc_window_display": false, 471 | "widenNotebook": false 472 | } 473 | }, 474 | "nbformat": 4, 475 | "nbformat_minor": 2 476 | } 477 | -------------------------------------------------------------------------------- /ch06/6-cli-interface.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": { 6 | "ein.tags": "worksheet-0", 7 | "slideshow": { 8 | "slide_type": "-" 9 | } 10 | }, 11 | "source": [ 12 | "# Flask命令行接口\n", 13 | "\n", 14 | "在之前的章节 **Flask框架简介** 中我们已经使用过Flask的命令行接口。\n", 15 | "\n", 16 | "\n", 17 | "## Click\n", 18 | "\n", 19 | "[Click](https://github.com/pallets/click) 是 Flask 的开发团队 [Pallets](https://github.com/pallets) 的另一款开源工具,用于快速创建命令行命令。Python 内置了一个 **Argparse** 的标准库用于创建命令行,但使用起来有些繁琐。<sup><a id=\"fnr.1\" class=\"footref\" href=\"#fn.1\">1</a></sup>\n", 20 | "\n", 21 | "\n", 22 | "### 快速使用\n", 23 | "\n", 24 | "Click 的使用大致有两个步骤:\n", 25 | "\n", 26 | "- 使用`@click.command()`装饰一个函数,使之成为命令行接口;\n", 27 | "\n", 28 | "- 使用`@click.option()`等装饰函数,为其添加命令行选项等。\n", 29 | "\n", 30 | "它的一种典型使用形式如下:\n", 31 | "\n", 32 | "```python\n", 33 | "import click\n", 34 | "\n", 35 | "@click.command()\n", 36 | "@click.option('--param', default=default_value, help='description')\n", 37 | "def func(param):\n", 38 | " pass\n", 39 | "```\n", 40 | "\n", 41 | "看一下官方文档的入门例子:\n", 42 | "\n", 43 | "```python\n", 44 | "import click\n", 45 | "\n", 46 | "@click.command()\n", 47 | "@click.option('--count', default=1, help='Number of greetings.')\n", 48 | "@click.option('--name', prompt='Your name', help='The person to greet.')\n", 49 | "def hello(count, name):\n", 50 | " \"\"\"Simple program that greets NAME for a total of COUNT times.\"\"\"\n", 51 | " for x in range(count):\n", 52 | " click.echo('Hello %s!' % name)\n", 53 | "\n", 54 | "if __name__ == '__main__':\n", 55 | " hello()\n", 56 | "```\n", 57 | "\n", 58 | "在上面的例子中,函数`hello`有两个参数:`count`和`name`,它们的值从命令行中获取。\n", 59 | "\n", 60 | "- `@click.command()`使函数`hello`成为命令行接口;\n", 61 | "- `@click.option`的第一个参数指定了命令行选项的名称,`count`的默认值是`1`,`name`的值从输入获取;\n", 62 | "\n", 63 | "- 使用`click.echo`进行输出是为了获得更好的兼容性,因为`print`在 Python2 和 Python3 的用法有些差别。\n", 64 | "\n", 65 | "\n", 66 | "### `click.option`\n", 67 | "\n", 68 | "`option`最基本的用法就是通过指定命令行选项的名称,从命令行读取参数值,再将其传递给函数。在上面的例子,除了设置命令行选项的名称,还会指定默认值,help 说明等,`option`常用的设置参数如下:\n", 69 | "\n", 70 | "- `default`:设置命令行参数的默认值\n", 71 | "- `help`:参数说明\n", 72 | "- `type`:参数类型,可以是`string`、`int`、`float`等\n", 73 | "- `prompt`:当在命令行中没有输入相应的参数时,会根据`prompt`提示用户输入\n", 74 | "- `nargs`:指定命令行参数接收的值的个数\n", 75 | "\n", 76 | "\n", 77 | "#### 指定 `type`\n", 78 | "\n", 79 | "可以使用`type`来指定参数类型:\n", 80 | "\n", 81 | "```python\n", 82 | "import click\n", 83 | "\n", 84 | "@click.command()\n", 85 | "@click.option('--rate', type=float, help='rate')\n", 86 | "def show(rate):\n", 87 | " click.echo('rate: %s' % rate)\n", 88 | "\n", 89 | "if __name__ == '__main__':\n", 90 | " show()\n", 91 | "```\n", 92 | "\n", 93 | "```\n", 94 | "$ python click_type.py --rate 1\n", 95 | "rate: 1.0\n", 96 | "$ python click_type.py --rate 0.66\n", 97 | "rate: 0.66\n", 98 | "```\n", 99 | "\n", 100 | "#### 可选值\n", 101 | "\n", 102 | "在某些情况下,一个参数的值只能是某些可选的值,如果用户输入了其他值, 应该提示用户输入正确的值。在这种情况下,可以通过`click.Choice()`来限定:\n", 103 | "\n", 104 | "```python\n", 105 | "import click\n", 106 | "\n", 107 | "@click.command()\n", 108 | "@click.option('--gender', type=click.Choice(['man', 'woman']))\n", 109 | "def choose(gender):\n", 110 | " click.echo('gender: %s' % gender)\n", 111 | "\n", 112 | "if __name__ == '__main__':\n", 113 | " choose()\n", 114 | "```\n", 115 | "\n", 116 | " $ python click_choice.py --gender boy\n", 117 | " Usage: click_choice.py [OPTIONS]\n", 118 | "\n", 119 | " Error: Invalid value for \"--gender\": invalid choice: boy. (choose from man, woman)\n", 120 | "\n", 121 | " $ python click_choice.py --gender man\n", 122 | " gender: man\n", 123 | "\n", 124 | "\n", 125 | "#### 多值参数\n", 126 | "\n", 127 | "有时,一个参数需要接收多个值。`option`支持设置固定长度的参数值,通过`nargs`指定。\n", 128 | "\n", 129 | "```python\n", 130 | "import click\n", 131 | "\n", 132 | "@click.command()\n", 133 | "@click.option('--center', nargs=2, type=float, help='center of the circle')\n", 134 | "@click.option('--radius', type=float, help='radius of the circle')\n", 135 | "def circle(center, radius):\n", 136 | " click.echo('center: %s, radius: %s' % (center, radius))\n", 137 | "\n", 138 | "if __name__ == '__main__':\n", 139 | " circle()\n", 140 | "```\n", 141 | "\n", 142 | "`option`指定了两个参数:`center`和`radius`,`center`表示二维平面上一个圆的圆心坐标,接收两个值,以元组的形式将值传递给函数,`radius`表示圆的半径。\n", 143 | "\n", 144 | " $ python click_multi_values.py --center 3 4 --radius 10\n", 145 | " center: (3.0, 4.0), radius: 10.0\n", 146 | "\n", 147 | " $ python click_multi_values.py --center 3 4 5 --radius 10\n", 148 | " Usage: click_multi_values.py [OPTIONS]\n", 149 | "\n", 150 | " Error: Got unexpected extra argument (5)\n", 151 | "\n", 152 | "\n", 153 | "#### 输入密码\n", 154 | "\n", 155 | "`option`提供了两个参数来设置密码的输入:\n", 156 | "\n", 157 | "- `hide_input`:用于隐藏输入\n", 158 | "- `confirmation_promt`:用于确认输入\n", 159 | "\n", 160 | "```python\n", 161 | "import click\n", 162 | "\n", 163 | "@click.command()\n", 164 | "@click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True)\n", 165 | "def input_password(password):\n", 166 | " click.echo('password: %s' % password)\n", 167 | "\n", 168 | "if __name__ == '__main__':\n", 169 | " input_password()\n", 170 | "```\n", 171 | "\n", 172 | " $ python click_password.py\n", 173 | " Password:\n", 174 | " Repeat for confirmation:\n", 175 | " password: 666666\n", 176 | "\n", 177 | "Click 也提供了一种快捷的方式,通过使用`@click.password_option()`,上面的代码可以简写成:\n", 178 | "\n", 179 | "```python\n", 180 | "import click\n", 181 | "\n", 182 | "@click.command()\n", 183 | "@click.password_option()\n", 184 | "def input_password(password):\n", 185 | " click.echo('password: %s' % password)\n", 186 | "\n", 187 | "if __name__ == '__main__':\n", 188 | " input_password()\n", 189 | "```\n", 190 | "\n", 191 | "\n", 192 | "#### 改变命令行程序的执行\n", 193 | "\n", 194 | "有些参数会改变命令行程序的执行,比如在终端输入`python`是进入 Python 控制台, 而输入`python --version`是打印 Python 版本。Click 提供`eager`标识对参数名进行标识,如果输入该参数,则会拦截既定的命令行执行流程, 跳转去执行一个回调函数。\n", 195 | "\n", 196 | "```python\n", 197 | "import click\n", 198 | "\n", 199 | "def print_version(ctx, param, value):\n", 200 | " if not value or ctx.resilient_parsing:\n", 201 | " return\n", 202 | " click.echo('Version 1.0')\n", 203 | " ctx.exit()\n", 204 | "\n", 205 | "@click.command()\n", 206 | "@click.option('--version', is_flag=True, callback=print_version,\n", 207 | " expose_value=False, is_eager=True)\n", 208 | "@click.option('--name', default='Ethan', help='name')\n", 209 | "def hello(name):\n", 210 | " click.echo('Hello %s!' % name)\n", 211 | "\n", 212 | "if __name__ == '__main__':\n", 213 | " hello()\n", 214 | "```\n", 215 | "\n", 216 | "其中:\n", 217 | "\n", 218 | "- `is_eager=True`表明该命令行选项优先级高于其他选项;\n", 219 | "- `expose_value=False`表示如果没有输入该命令行选项,会执行既定的命令行流程;\n", 220 | "- `callback`指定了输入该命令行选项时,要跳转执行的函数;\n", 221 | "\n", 222 | " ``` \n", 223 | " $ python click_eager.py\n", 224 | " Hello Ethan!\n", 225 | "\n", 226 | " $ python click_eager.py --version\n", 227 | " Version 1.0\n", 228 | "\n", 229 | " $ python click_eager.py --name Michael\n", 230 | " Hello Michael!\n", 231 | "\n", 232 | " $ python click_eager.py --version --name Ethan\n", 233 | " Version 1.0\n", 234 | " ```\n", 235 | "\n", 236 | "### `click.argument`\n", 237 | "\n", 238 | "除了使用`@click.option`来添加可选参数,还经常使用`@click.argument`来添加固定参数。\n", 239 | "\n", 240 | "一个简单的例子:\n", 241 | "\n", 242 | "```python\n", 243 | "import click\n", 244 | "\n", 245 | "@click.command()\n", 246 | "@click.argument('coordinates')\n", 247 | "def show(coordinates):\n", 248 | " click.echo('coordinates: %s' % coordinates)\n", 249 | "\n", 250 | "if __name__ == '__main__':\n", 251 | " show()\n", 252 | "```\n", 253 | "\n", 254 | " $ python click_argument.py\n", 255 | " Usage: click_argument.py [OPTIONS] COORDINATES\n", 256 | "\n", 257 | " Error: Missing argument \"coordinates\".\n", 258 | "\n", 259 | " $ python click_argument.py --help\n", 260 | " Usage: click_argument.py [OPTIONS] COORDINATES\n", 261 | "\n", 262 | " Options:\n", 263 | " --help Show this message and exit.\n", 264 | "\n", 265 | " $ python click_argument.py --coordinates 10\n", 266 | " Error: no such option: --coordinates\n", 267 | "\n", 268 | " $ python click_argument.py 10\n", 269 | " coordinates: 10\n", 270 | "\n", 271 | "\n", 272 | "#### 多个 `argument`\n", 273 | "\n", 274 | "```python\n", 275 | "import click\n", 276 | "\n", 277 | "@click.command()\n", 278 | "@click.argument('x')\n", 279 | "@click.argument('y')\n", 280 | "@click.argument('z')\n", 281 | "def show(x, y, z):\n", 282 | " click.echo('x: %s, y: %s, z:%s' % (x, y, z))\n", 283 | "\n", 284 | "if __name__ == '__main__':\n", 285 | " show()\n", 286 | "```\n", 287 | "\n", 288 | " $ python click_argument.py 10 20 30\n", 289 | " x: 10, y: 20, z:30\n", 290 | "\n", 291 | " $ python click_argument.py 10\n", 292 | " Usage: click_argument.py [OPTIONS] X Y Z\n", 293 | "\n", 294 | " Error: Missing argument \"y\".\n", 295 | "\n", 296 | " $ python click_argument.py 10 20\n", 297 | " Usage: click_argument.py [OPTIONS] X Y Z\n", 298 | "\n", 299 | " Error: Missing argument \"z\".\n", 300 | "\n", 301 | " $ python click_argument.py 10 20 30 40\n", 302 | " Usage: click_argument.py [OPTIONS] X Y Z\n", 303 | "\n", 304 | " Error: Got unexpected extra argument (40)\n", 305 | "\n", 306 | "\n", 307 | "#### 不定参数\n", 308 | "\n", 309 | "`argument`还有另外一种常见的用法,就是接收不定量的参数:\n", 310 | "\n", 311 | "```python\n", 312 | "import click\n", 313 | "\n", 314 | "@click.command()\n", 315 | "@click.argument('src', nargs=-1)\n", 316 | "@click.argument('dst', nargs=1)\n", 317 | "def move(src, dst):\n", 318 | " click.echo('move %s to %s' % (src, dst))\n", 319 | "\n", 320 | "if __name__ == '__main__':\n", 321 | " move()\n", 322 | "```\n", 323 | "\n", 324 | "其中, `nargs=-1` 表明参数`src`接收不定量的参数值,参数值会以元组的形式传入函数。如果`nargs`大于等于 1,表示接收`nargs`个参数值,上面的例子中,`dst`接收一个参数值。\n", 325 | "\n", 326 | " $ python click_argument.py file1 trash\n", 327 | " move (u'file1',) to trash\n", 328 | "\n", 329 | " $ python click_argument.py file1 file2 file3 trash\n", 330 | " move (u'file1', u'file2', u'file3') to trash\n", 331 | "\n", 332 | "\n", 333 | "### 彩色输出\n", 334 | "\n", 335 | "使用`click.echo`进行输出,如果配合 [colorama](https://github.com/tartley/colorama) 这个模块,可以使用`click.secho`进行彩色输出。\n", 336 | "\n", 337 | "使用 pip 安装 colorama:\n", 338 | "\n", 339 | "```sh\n", 340 | "(flaskr_env3) $ pip install colorama\n", 341 | "```\n", 342 | "\n", 343 | "```python\n", 344 | "import click\n", 345 | "\n", 346 | "@click.command()\n", 347 | "@click.option('--name', help='The person to greet.')\n", 348 | "def hello(name):\n", 349 | " click.secho('Hello %s!' % name, fg='red', underline=True)\n", 350 | " click.secho('Hello %s!' % name, fg='yellow', bg='black')\n", 351 | "\n", 352 | "if __name__ == '__main__':\n", 353 | " hello()\n", 354 | "```\n", 355 | "\n", 356 | "- `fg`表示前景色(即字体颜色),可选值:\n", 357 | " - `BLACK`\n", 358 | " - `RED`\n", 359 | " - `GREEN`\n", 360 | " - `YELLOW`\n", 361 | " - `BLUE`\n", 362 | " - `MAGENTA`\n", 363 | " - `CYAN`\n", 364 | " - `WHITE`\n", 365 | " - …\n", 366 | "\n", 367 | "- `bg`表示背景色,可选值:\n", 368 | " - `BLACK`\n", 369 | " - `RED`\n", 370 | " - `GREEN`\n", 371 | " - `YELLOW`\n", 372 | " - `BLUE`\n", 373 | " - `MAGENTA`\n", 374 | " - `CYAN`\n", 375 | " - `WHITE`\n", 376 | " - …\n", 377 | "\n", 378 | "- `underline`表示下划线,可选的样式:\n", 379 | " - `dim=True`\n", 380 | " - `bold=True`\n", 381 | "\n", 382 | "\n", 383 | "### 小结\n", 384 | "\n", 385 | "- 使用`click.command()`装饰一个函数,使其成为命令行接口。\n", 386 | "- 使用`click.option()`添加可选参数,支持设置固定长度的参数值。\n", 387 | "- 使用`click.argument()`添加固定参数,支持设置不定长度的参数值。\n", 388 | "\n", 389 | "\n", 390 | "## 运行Shell\n", 391 | "\n", 392 | "使用下面的shell命令来开启一个交互式的Python shell:\n", 393 | "\n", 394 | " (flaskr_env3) $ flask shell\n", 395 | "\n", 396 | "这将开启一个交互式的Python shell,并且在其中设置好了正确的应用上下文和 本地变量(local variables)。这是通过调用应用的`Flask.make_shell_context()`方法做到的。默认地你将可访问到`app`和`g`。\n", 397 | "\n", 398 | "\n", 399 | "## 自定义命令\n", 400 | "\n", 401 | "Flask 使用 [click](http://click.pocoo.org/) 来实现命令行接口,这使得添加自定义命令非常容易。例如,如果你想 要一个shell 命令来初始化数据库,你可以这样做:\n", 402 | "\n", 403 | "```python\n", 404 | "import click\n", 405 | "from flask import Flask\n", 406 | "\n", 407 | "app = Flask(__name__)\n", 408 | "\n", 409 | "@app.cli.command()\n", 410 | "def initdb():\n", 411 | " \"\"\"Initialize the database.\"\"\"\n", 412 | " click.echo('Init the db')\n", 413 | "```\n", 414 | "\n", 415 | "在命令行中执行这个命令:\n", 416 | "\n", 417 | "```sh\n", 418 | "(flaskr_env3) $ flask initdb\n", 419 | "Init the db\n", 420 | "```\n", 421 | "\n", 422 | "\n", 423 | "## 应用上下文\n", 424 | "\n", 425 | "大多数命令需要对应用对象执行操作,所以为它们设置好应用上下文非常有必要。正因为如此,如果你在`app.cli`上通过`command()`注册了一个回调,这个回调将自动被`cli.with_appcontext()`包装(wrapped)来通知命令行系统确保设置好一个应用上下文。如果一个命令是通过`add_command`或其他方法稍后添加的,那这种行为将不可用。\n", 426 | "\n", 427 | "这种行为可以通过向装饰器传递`with_appcontext=False`来禁用:\n", 428 | "\n", 429 | "```python\n", 430 | "@app.cli.command(with_appcontext=False)\n", 431 | "def example():\n", 432 | " pass\n", 433 | "```\n", 434 | "\n", 435 | "## 脚注\n", 436 | "\n", 437 | "<sup><a id=\"fn.1\" class=\"footnum\" href=\"#fnr.1\">1</a></sup> Click 相比于 Argparse,就好比 requests 相比于 urllib 。" 438 | ] 439 | } 440 | ], 441 | "metadata": { 442 | "kernelspec": { 443 | "display_name": "Python 3", 444 | "name": "python3" 445 | }, 446 | "name": "6-cli-interface.ipynb" 447 | }, 448 | "nbformat": 4, 449 | "nbformat_minor": 2 450 | } 451 | -------------------------------------------------------------------------------- /ch07/7-large-app-structure.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": { 6 | "ein.tags": "worksheet-0", 7 | "slideshow": { 8 | "slide_type": "-" 9 | } 10 | }, 11 | "source": [ 12 | "# Flask大型应用程序结构\n", 13 | "\n", 14 | "Flask并不强制要求大型项目使用特定的组织方式,程序结构的组织方式完全由开发者决定。\n", 15 | "\n", 16 | "下面我们将介绍一种使用包和模块组织大型程序的方式。\n", 17 | "\n", 18 | "\n", 19 | "## 项目结构\n", 20 | "\n", 21 | "Flask程序的基本结构如下:\n", 22 | "\n", 23 | " │Project\n", 24 | " ├── README.md\n", 25 | " ├── app\n", 26 | " │ ├── __init__.py\n", 27 | " │ ├── api\n", 28 | " │ ├── auth\n", 29 | " │ ├── decorators.py\n", 30 | " │ ├── main\n", 31 | " │ │ ├── __init__.py\n", 32 | " │ │ ├── errors.py\n", 33 | " │ │ ├── forms.py\n", 34 | " │ │ └── views.py\n", 35 | " │ ├── models.py\n", 36 | " │ ├── static\n", 37 | " │ └── templates\n", 38 | " ├── config.py\n", 39 | " ├── manage.py\n", 40 | " ├── migrations\n", 41 | " ├── tests\n", 42 | " ├── prod\n", 43 | " ├── requirements\n", 44 | " └── utils\n", 45 | "\n", 46 | "顶级文件夹:\n", 47 | "\n", 48 | "- Flask程序一般都保存在名为`app`的包中;\n", 49 | "- `migrations`文件夹包含数据库迁移脚本;\n", 50 | "- 单元测试编写在`tests`包中;\n", 51 | "- `prod`文件夹包含生产环境部署配置文件;\n", 52 | "- `utils`包中放置工具函数或者可独立使用的库;\n", 53 | "- `requirements`文件夹包含所有依赖包(不同环境),用来生成虚拟环境。\n", 54 | "\n", 55 | "一些文件:\n", 56 | "\n", 57 | "- `config.py`存储配置;\n", 58 | "- `manage.py`用于指定`flask`命令的运行的程序和其他任务命令;\n", 59 | "- `README.md`项目介绍。\n", 60 | "\n", 61 | "下面几节介绍如何把`hello.py`程序转换成上面这种结构。\n", 62 | "\n", 63 | "\n", 64 | "## 配置选项\n", 65 | "\n", 66 | "程序经常需要设定多个配置,比如开发、测试和生产环境要使用不同的数据库。\n", 67 | "\n", 68 | "下面的代码展示了使用层次结构的配置类。\n", 69 | "\n", 70 | "```python\n", 71 | "# config.py\n", 72 | "\n", 73 | "import os\n", 74 | "basedir = os.path.abspath(os.path.dirname(__file__))\n", 75 | "\n", 76 | "\n", 77 | "class Config(object):\n", 78 | " PROJECT_DIR = basedir\n", 79 | " SECRET_KEY = (os.environ.get('SECRET_KEY') or\n", 80 | " '44617457d542163d10ada66726b31ef80a88ac1a41013ea5')\n", 81 | "\n", 82 | " PEEWEE_MODELS_MODULE = 'app.models'\n", 83 | "\n", 84 | " @classmethod\n", 85 | " def init_app(cls, app):\n", 86 | " pass\n", 87 | "\n", 88 | "\n", 89 | "class DevelopmentConfig(Config):\n", 90 | " DEBUG = True\n", 91 | " PEEWEE_DATABASE_URI = (\n", 92 | " os.environ.get('DEV_DATABASE_URL') or\n", 93 | " 'sqlite:///' + os.path.join(basedir, 'data-dev.sqlite')\n", 94 | " )\n", 95 | "\n", 96 | "\n", 97 | "class TestingConfig(Config):\n", 98 | " TESTING = True\n", 99 | " PEEWEE_DATABASE_URI = (\n", 100 | " os.environ.get('DEV_DATABASE_URL') or\n", 101 | " 'sqlite:///' + os.path.join(basedir, 'data-test.sqlite')\n", 102 | " )\n", 103 | "\n", 104 | "\n", 105 | "class ProductionConfig(Config):\n", 106 | " DEBUG = False\n", 107 | "\n", 108 | " PEEWEE_DATABASE_URI = (\n", 109 | " os.environ.get('PROD_DATABASE_URL') or\n", 110 | " 'sqlite:///' + os.path.join(basedir, 'data.sqlite')\n", 111 | " )\n", 112 | "\n", 113 | "\n", 114 | "config = {\n", 115 | " 'development': DevelopmentConfig,\n", 116 | " 'testing': TestingConfig,\n", 117 | " 'production': ProductionConfig,\n", 118 | "\n", 119 | " 'default': DevelopmentConfig\n", 120 | "}\n", 121 | "```\n", 122 | "\n", 123 | "基类`Config`中包含通用配置,子类分别定义专用的配置。如果需要,你还可添加其他配置类。\n", 124 | "\n", 125 | "为了让配置方式更灵活且更安全,某些配置可以从环境变量中导入。例如,`SECRET_KEY`的值,可以在环境中设定,但系统也提供了一个默认值,以防环境中没有定义。\n", 126 | "\n", 127 | "基类`Config`中变量`PEEWEE_MODELS_MODULE`是 **Flask-PW** 扩展的设置选项,用来指定程序的数据模型定义模块的路径。\n", 128 | "\n", 129 | "在3个子类中,`PEEWEE_DATABASE_URI`变量都被指定了不同的值。这样程序在不同的配置环境中运行就可使用不同的数据库。\n", 130 | "\n", 131 | "配置类可以定义`init_app()`类方法,其参数是程序实例。在这个方法中,可以执行对当前环境的配置初始化。现在,基类`Config`中的`init_app()`方法为空。\n", 132 | "\n", 133 | "配置脚本末尾,`config`字典中注册了不同的配置环境,而且还注册了一个默认配置(开发环境)。\n", 134 | "\n", 135 | "\n", 136 | "## 程序包\n", 137 | "\n", 138 | "程序包用来保存程序的所有代码、模板和静态文件。可以把这个包直接称为`app`(应用),如果有需求,也可使用一个程序专用名字。\n", 139 | "\n", 140 | "`templates`和`static`文件夹是程序包的一部分,因此到后面这两个文件夹被移到了`app`中。\n", 141 | "\n", 142 | "数据库模型被移到了这个包中,保存为`app/models.py`。\n", 143 | "\n", 144 | "\n", 145 | "### 程序工厂函数\n", 146 | "\n", 147 | "为了动态修改配置或在不同的配置环境中运行程序,可以延迟创建程序实例,将其创建过程移到可显式调用的工厂函数中。这样不仅可以给脚本留出配置程序的时间,还能够创建多个程序实例,这些实例有时在测试中非常有用。\n", 148 | "\n", 149 | "程序的工厂函数在`app`包的构造文件中定义,如下所示:\n", 150 | "\n", 151 | "```python\n", 152 | "# app/__init__.py\n", 153 | "from flask import Flask\n", 154 | "\n", 155 | "from flask_bootstrap import Bootstrap\n", 156 | "from flask_moment import Moment\n", 157 | "from flask_pw import Peewee\n", 158 | "\n", 159 | "from config import config\n", 160 | "\n", 161 | "\n", 162 | "bootstrap = Bootstrap()\n", 163 | "moment = Moment()\n", 164 | "db = Peewee()\n", 165 | "\n", 166 | "\n", 167 | "def create_app(config_name):\n", 168 | " app = Flask(__name__)\n", 169 | " app.config.from_object(config[config_name])\n", 170 | " config[config_name].init_app(app)\n", 171 | "\n", 172 | " bootstrap.init_app(app)\n", 173 | " moment.init_app(app)\n", 174 | " db.init_app(app)\n", 175 | " app.cli.add_command(db.cli, 'db')\n", 176 | "\n", 177 | " # 附加路由和自定义的错误页面\n", 178 | "\n", 179 | " return app\n", 180 | "```\n", 181 | "\n", 182 | "工厂函数返回创建的程序示例,现在工厂函数创建的程序还不完整,因为没有路由和自定义的错误页面处理程序。\n", 183 | "\n", 184 | "\n", 185 | "### Flask蓝图\n", 186 | "\n", 187 | "在单脚本程序中,程序实例存在于全局作用域中,路由可以直接使用`app.route`装饰器定义。但现在程序在运行时创建,只有调用`create_app()`之后才能使用`app.route`装饰器,这时定义路由就太晚了。另外,自定义的错误页面处理程序也面临相同的困难,因为错误页面处理程序使用`app.errorhandler`装饰器定义。\n", 188 | "\n", 189 | "Flask使用蓝图(Blueprint)提供了更好地解决方法。\n", 190 | "\n", 191 | "蓝图实现了应用的模块化,其和程序类似,也可以定义路由,在蓝图中定义的路由处于休眠状态,直到蓝图注册到程序上后,路由才真正成为程序的一部分。蓝图通常作用于相同的URL前缀,比如`/user/:id`、`/user/profile`这样的地址,都以`/user`开头,那么它们就可以放在一个模块中。\n", 192 | "\n", 193 | "使用蓝图让应用层次清晰,开发者可以更容易地开发和维护项目。\n", 194 | "\n", 195 | "看一个简单的示例:\n", 196 | "\n", 197 | "```python\n", 198 | "# user.py\n", 199 | "from flask import Blueprint\n", 200 | "\n", 201 | "bp = Blueprint('user', __name__, url_prefix='/user')\n", 202 | "\n", 203 | "\n", 204 | "@bp.route('/')\n", 205 | "def index():\n", 206 | " return 'User\"s index page'\n", 207 | "```\n", 208 | "\n", 209 | "通过实例化一个`Blueprint`类对象来创建蓝图。这个构造函数有两个必须指定的参数: 蓝图的名字和蓝图所在的包或模块。和程序一样,大多数情况下第二个参数使用 Python 的`__name__`变量即可。`url_prefix`是可选参数,如果设定,注册蓝图后其中 定义的所有路由都会加上指定的前缀,此例中为`/user`。\n", 210 | "\n", 211 | "再看主程序:\n", 212 | "\n", 213 | "```python\n", 214 | "# app.py\n", 215 | "from flask import Flask\n", 216 | "import user\n", 217 | "\n", 218 | "\n", 219 | "app = Flask(__name__)\n", 220 | "app.register_blueprint(user.bp)\n", 221 | "```\n", 222 | "\n", 223 | "使用`register_blueprint`注册模块,如果想去掉模块只需要去掉对应的注册语句即可。\n", 224 | "\n", 225 | "为了获得最大的灵活性,程序包中创建了一个子包,用于保存蓝图。下面的示例是这个子包的构造文件,蓝图就创建于此。\n", 226 | "\n", 227 | "```python\n", 228 | "# app/main/__init__.py\n", 229 | "from flask import Blueprint\n", 230 | "\n", 231 | "main = Blueprint('main', __name__)\n", 232 | "\n", 233 | "from . import views, errors\n", 234 | "```\n", 235 | "\n", 236 | "程序的路由保存在包里的`app/main/views.py`模块中,而错误处理程序保存在`app/main/errors.py`模块中。导入这两个模块就能把路由和错误处理程序与蓝图关联起来。\n", 237 | "\n", 238 | "**⚠️ 这些模块必须在`app/main/__init__.py`脚本的末尾导入,** 为了避免循环导入依赖,因为在`views.py`和`errors.py`中还要导入蓝图`main`。\n", 239 | "\n", 240 | "蓝图在工厂函数`create_app()`中注册到程序上,如下所示:\n", 241 | "\n", 242 | "```python\n", 243 | "# app/__init__.py\n", 244 | "def create_app(config_name):\n", 245 | " # ...\n", 246 | "\n", 247 | " from .main import main as main_blueprint\n", 248 | " app.register_blueprint(main_blueprint)\n", 249 | "\n", 250 | " return app\n", 251 | "```\n", 252 | "\n", 253 | "下面是错误处理程序:\n", 254 | "\n", 255 | "```python\n", 256 | "# app/main/errors.py\n", 257 | "from flask import render_template\n", 258 | "\n", 259 | "from . import main\n", 260 | "\n", 261 | "\n", 262 | "@main.app_errorhandler(404)\n", 263 | "def page_not_found(e):\n", 264 | " return render_template('404.html'), 404\n", 265 | "\n", 266 | "\n", 267 | "@main.app_errorhandler(500)\n", 268 | "def internal_server_error(e):\n", 269 | " return render_template('500.html'), 500\n", 270 | "```\n", 271 | "\n", 272 | "在蓝图中编写错误处理程序稍有不同,如果使用`errorhandler`装饰器,那么只有蓝图中的 错误才能触发处理程序。要想注册程序全局的错误处理程序,必须使用`app_errorhandler`。\n", 273 | "\n", 274 | "在蓝图中定义程序路由:\n", 275 | "\n", 276 | "```python\n", 277 | "# app/main/views.py\n", 278 | "from datetime import datetime\n", 279 | "\n", 280 | "from flask import render_template, session, redirect, url_for\n", 281 | "\n", 282 | "from . import main\n", 283 | "from .forms import NameForm\n", 284 | "from .. import db\n", 285 | "from ..models import User\n", 286 | "\n", 287 | "\n", 288 | "@main.route('/', methods=['GET', 'POST'])\n", 289 | "def index():\n", 290 | " form = NameForm()\n", 291 | " if form.validate_on_submit():\n", 292 | " # ...\n", 293 | " return redirect(url_for('.index'))\n", 294 | " return render_template('index.html',\n", 295 | " form=form, name=session.get('name'),\n", 296 | " known=session.get('known', False),\n", 297 | " current_time=datetime.utcnow())\n", 298 | "```\n", 299 | "\n", 300 | "在蓝图中编写视图函数主要有两点不同:\n", 301 | "\n", 302 | "1. 和前面的错误处理程序一样,路由装饰器由蓝图提供;\n", 303 | "2. `url_for()`函数的用法不同。\n", 304 | "\n", 305 | " `url_for()`函数的第一个参数是路由的端点名,在程序的路由中,默认为视图函数的名字。例如,在单脚本程序中,`index()`视图函数的URL可使用`url_for('index')`获取。\n", 306 | "\n", 307 | " 蓝图中就不一样了,Flask会为蓝图中的全部端点加上一个命名空间,这样就可以在不 同的蓝图中使用相同的端点名定义视图函数,而不会产生冲突。命名空间就是蓝图的名字(`Blueprint`构造函数的第一个参数),所以视图函数`index()`注册的端点名是`main.index`,其URL使用`url_for('main.index')`获取。\n", 308 | "\n", 309 | " `url_for()`函数还支持一种简写的端点形式,在蓝图中可以省略蓝图名,例如`url_for('.index')`。在这种写法中,命名空间是当前请求所在的蓝图。这意味着同一蓝图中的重定向可以使用简写形式,但跨蓝图的重定向必须使用带有命名空间 的端点名。\n", 310 | "\n", 311 | "此外,表单对象也要移到蓝图中,保存于`app/main/forms.py`模块。\n", 312 | "\n", 313 | "\n", 314 | "## 命令行脚本\n", 315 | "\n", 316 | "顶级文件夹中的`manage.py`用于指定`flask`命令的运行的程序和其他任务命令。\n", 317 | "\n", 318 | "```python\n", 319 | "# manage.py\n", 320 | "import os\n", 321 | "import sys\n", 322 | "\n", 323 | "import click\n", 324 | "\n", 325 | "from app import create_app, db\n", 326 | "\n", 327 | "\n", 328 | "app = create_app(os.getenv('FLASK_CONFIG') or 'default')\n", 329 | "\n", 330 | "\n", 331 | "@app.cli.command()\n", 332 | "def create_tables():\n", 333 | " db.database.create_tables(db.models)\n", 334 | "```\n", 335 | "\n", 336 | "这个脚本先创建程序。如果已经定义了环境变量`FLASK_CONFIG`,则从中读取配置名;否则使用默认配置。\n", 337 | "\n", 338 | "另外,添加了一个命令`create_tables`,用来根据定义的模型创建数据库表。\n", 339 | "\n", 340 | "\n", 341 | "## 需求文件\n", 342 | "\n", 343 | "程序中必须包含一个`requirements.txt`文件,用于记录所有依赖包及其精确的版本号。\n", 344 | "\n", 345 | "这里我们使用一个工具 [pip-tools](https://github.com/jazzband/pip-tools) 来进行依赖包管理。\n", 346 | "\n", 347 | "**pip-tools = pip-compile + pip-sync**\n", 348 | "\n", 349 | "有两个文件来进行依赖包管理:\n", 350 | "\n", 351 | "- `requirements.in`(手动创建)包含了项目中直接使用到的包\n", 352 | "- `requirements.txt`(通过`pip-compile requirements.in`创建)包含所有包包括依赖包\n", 353 | "\n", 354 | "项目中如果用到新的依赖包,将包的名字添加到`requirements.in`中,然后使用`pip-compile requirements.in`生成`requirements.txt`文件,再使用`pip-sync requirements.txt`安装所有包。\n", 355 | "\n", 356 | "更多关于`pip-tools`的使用介绍,请参考其文档。\n", 357 | "\n", 358 | "下面所示为`requirements.in`文件的内容:\n", 359 | "\n", 360 | " flask\n", 361 | " flask-bootstrap\n", 362 | " flask-wtf\n", 363 | " flask-moment\n", 364 | " flask-pw\n", 365 | " pip-tools\n", 366 | "\n", 367 | "\n", 368 | "## 单元测试\n", 369 | "\n", 370 | "为了演示,编写两个简单的测试:\n", 371 | "\n", 372 | "```python\n", 373 | "# tests/test_basics.py\n", 374 | "import unittest\n", 375 | "\n", 376 | "from flask import current_app\n", 377 | "from app import create_app, db\n", 378 | "\n", 379 | "\n", 380 | "class BasicsTestCase(unittest.TestCase):\n", 381 | " def setUp(self):\n", 382 | " self.app = create_app('testing')\n", 383 | " self.app_context = self.app.app_context()\n", 384 | " self.app_context.push()\n", 385 | " # create all db tables\n", 386 | "\n", 387 | " def tearDown(self):\n", 388 | " # drop all db tables\n", 389 | " self.app_context.pop()\n", 390 | "\n", 391 | " def test_app_exists(self):\n", 392 | " self.assertFalse(current_app is None)\n", 393 | "\n", 394 | " def test_app_is_testing(self):\n", 395 | " self.assertTrue(current_app.config['TESTING'])\n", 396 | "```\n", 397 | "\n", 398 | "这个测试使用 Python 标准库中的`unittest`包编写。`setUp()`和`tearDown()`方法分别在各测试前后运行,并且名字以`test_`开头的函数 都作为测试执行。\n", 399 | "\n", 400 | "如果想进一步了解如何使用Python的`unittest`包编写测试,请阅读[官方文档](https://docs.python.org/3/library/unittest.html)。\n", 401 | "\n", 402 | "`setUp()`方法尝试创建一个测试环境,类似于运行中的程序。首先,使用测试配置创建程序,然后激活上下文。这一步的作用是确保能在测试中使用`current_app`,像普通请求一样。然后创建一个全新的数据库。数据库和程序上下文在`tearDown()`方法中删除。\n", 403 | "\n", 404 | "第一个测试确保程序实例存在。第二个测试确保程序在测试配置中运行。若想把`tests`文件夹作为包使用,需要添加`tests/__init__.py`文件,这个文件可以为空,因为`unittest`包会扫描所有模块并查找测试。\n", 405 | "\n", 406 | "为了运行单元测试,可以在`manage.py`脚本中添加一个自定义命令`test`。\n", 407 | "\n", 408 | "```python\n", 409 | "# manage.py\n", 410 | "@app.cli.command()\n", 411 | "def test():\n", 412 | " \"\"\"Run the unit tests.\"\"\"\n", 413 | " import unittest\n", 414 | " tests = unittest.TestLoader().discover('tests')\n", 415 | " unittest.TextTestRunner(verbosity=2).run(tests)\n", 416 | "```\n", 417 | "\n", 418 | "修饰函数名就是命令名,函数的文档字符串会显示在帮助消息中。`test()`函数的定义体中调用了`unittest`包提供的测试运行函数。\n", 419 | "\n", 420 | "单元测试可使用下面的命令运行:\n", 421 | "\n", 422 | "```sh\n", 423 | "(flaskr_env3) $ export FLASK_APP=manage.py\n", 424 | "(flaskr_env3) $ flask test\n", 425 | "test_app_exists (test_basics.BasicsTestCase) ... ok\n", 426 | "test_app_is_testing (test_basics.BasicsTestCase) ... ok\n", 427 | "\n", 428 | "----------------------------------------------------------------------\n", 429 | "Ran 2 tests in 0.003s\n", 430 | "\n", 431 | "OK\n", 432 | "```\n", 433 | "\n", 434 | "**🔖 执行`git checkout 7a`签出程序的这个版本。**\n", 435 | "\n", 436 | "\n", 437 | "## 新的创建数据库命令\n", 438 | "\n", 439 | "Flask-PW 提供了一个`db`命令,下面的代码展示了如何将新的创建数据库命令`createtables`添加`db`命令下面,作为其子命令使用。\n", 440 | "\n", 441 | "```python\n", 442 | "# manage.py\n", 443 | "from flask.cli import with_appcontext\n", 444 | "\n", 445 | "from flask_pw import BaseSignalModel\n", 446 | "\n", 447 | "\n", 448 | "@db.cli.command('createtables', short_help='Create database tables.')\n", 449 | "@click.option('--safe', default=False, is_flag=True,\n", 450 | " help=('Check first whether the table exists '\n", 451 | " 'before attempting to create it.'))\n", 452 | "@click.argument('models', nargs=-1, type=click.UNPROCESSED)\n", 453 | "@with_appcontext\n", 454 | "def create_tables(models, safe):\n", 455 | " from importlib import import_module\n", 456 | " from flask.globals import _app_ctx_stack\n", 457 | " app = _app_ctx_stack.top.app\n", 458 | " if models:\n", 459 | " pw_models = []\n", 460 | "\n", 461 | " module = import_module(app.config['PEEWEE_MODELS_MODULE'])\n", 462 | " for mod in models:\n", 463 | " model = getattr(module, mod)\n", 464 | " if not isinstance(model, BaseSignalModel):\n", 465 | " continue\n", 466 | " pw_models.append(model)\n", 467 | " if pw_models:\n", 468 | " db.database.create_tables(pw_models, safe)\n", 469 | " return\n", 470 | " db.database.create_tables(db.models, safe)\n", 471 | "```\n", 472 | "\n", 473 | "**🔖 执行`git checkout 7b`签出程序的这个版本。**" 474 | ] 475 | } 476 | ], 477 | "metadata": { 478 | "kernelspec": { 479 | "display_name": "Python 3", 480 | "language": "python", 481 | "name": "python3" 482 | }, 483 | "language_info": { 484 | "codemirror_mode": { 485 | "name": "ipython", 486 | "version": 3 487 | }, 488 | "file_extension": ".py", 489 | "mimetype": "text/x-python", 490 | "name": "python", 491 | "nbconvert_exporter": "python", 492 | "pygments_lexer": "ipython3", 493 | "version": "3.6.2" 494 | }, 495 | "name": "7-large-app-structure.ipynb", 496 | "toc": { 497 | "colors": { 498 | "hover_highlight": "#ddd", 499 | "running_highlight": "#FF0000", 500 | "selected_highlight": "#ccc" 501 | }, 502 | "moveMenuLeft": true, 503 | "nav_menu": { 504 | "height": "192px", 505 | "width": "252px" 506 | }, 507 | "navigate_menu": true, 508 | "number_sections": false, 509 | "sideBar": true, 510 | "threshold": 4, 511 | "toc_cell": false, 512 | "toc_section_display": "block", 513 | "toc_window_display": false, 514 | "widenNotebook": false 515 | } 516 | }, 517 | "nbformat": 4, 518 | "nbformat_minor": 2 519 | } 520 | -------------------------------------------------------------------------------- /ch09/9-user-roles.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": { 6 | "ein.tags": "worksheet-0", 7 | "slideshow": { 8 | "slide_type": "-" 9 | } 10 | }, 11 | "source": [ 12 | "# 用户角色\n", 13 | "\n", 14 | "有多种方法可用于在程序中实现角色。具体采用何种实现方法取决于所需角色的数量和细分程度。\n", 15 | "\n", 16 | "本章介绍的用户角色实现方式结合了分立的角色和权限,赋予用户分立的角色,但角色使用权限定义。\n", 17 | "\n", 18 | "\n", 19 | "## 角色在数据库中的表示\n", 20 | "\n", 21 | "下面是改进后的`Role`模型,增加了角色的权限:\n", 22 | "\n", 23 | "```python\n", 24 | "# app/models.py\n", 25 | "class Role(db.Model):\n", 26 | " name = pw.CharField(64, unique=True)\n", 27 | " default = pw.BooleanField(default=False, index=True)\n", 28 | " permissions = pw.IntegerField(null=True)\n", 29 | "```\n", 30 | "\n", 31 | "只有一个角色的`default`字段要设为`True`,其他都设为`False`。用户注册时,其角色会被设为默认角色。\n", 32 | "\n", 33 | "第二处改动是添加了`permissions`字段,其值是一个整数,表示位标志。各操作都对应一个位位置,能执行某项操作的角色,其位会被设为`1`。\n", 34 | "\n", 35 | "各操作所需的程序权限是不一样的,如下表所示:\n", 36 | "\n", 37 | "| 操作 | 位值 | 说明 |\n", 38 | "|-------------|---------------------|---------------|\n", 39 | "| 关注用户 | `0b00000001 (0x01)` | 关注其他用户 |\n", 40 | "| 在他人的文章中发表评论 | `0b00000010 (0x02)` | 在他人撰写的文章中发布评论 |\n", 41 | "| 写文章 | `0b00000100 (0x04)` | 写原创文章 |\n", 42 | "| 管理他人发表的评论 | `0b00001000 (0x08)` | 查处他人发表的不当评论 |\n", 43 | "| 管理员权限 | `0b10000000 (0x80)` | 管理网站 |\n", 44 | "\n", 45 | "⚠️ 操作的权限使用 8 位表示,现在只用了其中 5 位,其他 3 位可用于将来的扩充。\n", 46 | "\n", 47 | "使用如下的代码表示权限:\n", 48 | "\n", 49 | "```python\n", 50 | "# app/models.py\n", 51 | "class Permission:\n", 52 | " FOLLOW = 0x01\n", 53 | " COMMENT = 0x02\n", 54 | " WRITE_ARTICLES = 0x04\n", 55 | " MODERATE_COMMENTS = 0x08\n", 56 | " ADMINISTER = 0x80\n", 57 | "```\n", 58 | "\n", 59 | "下表列出了要支持的用户角色以及定义角色使用的位权限:\n", 60 | "\n", 61 | "| 用户角色 | 权限 | 说明 |\n", 62 | "|----------|---------------------|----------------------------------|\n", 63 | "| 匿名 | `0b00000000 (0x00)` | 未登录的用户。在程序中只有阅读权限 |\n", 64 | "| 用户 | `0b00000111 (0x07)` | 具有发布文章、发表评论和关注其他用户的权限。这是新用户的默认角色 |\n", 65 | "| 协管员 | `0b00001111 (0x0f)` | 增加审查不当评论的权限 |\n", 66 | "| 管理员 | `0b11111111 (0xff)` | 具有所有权限,包括修改其他用户所属角色的权限 |\n", 67 | "\n", 68 | "使用权限组织角色,以后添加新角色时只需使用不同的权限组合即可。\n", 69 | "\n", 70 | "在`Role`类中添加一个类方法用来将角色添加到数据库中:\n", 71 | "\n", 72 | "```python\n", 73 | "# app/models.py\n", 74 | "class Role(db.Model):\n", 75 | " # ...\n", 76 | " @staticmethod\n", 77 | " def insert_roles():\n", 78 | " roles = {\n", 79 | " 'User': (Permission.FOLLOW |\n", 80 | " Permission.COMMENT |\n", 81 | " Permission.WRITE_ARTICLES, True),\n", 82 | " 'Moderator': (Permission.FOLLOW |\n", 83 | " Permission.COMMENT |\n", 84 | " Permission.WRITE_ARTICLES |\n", 85 | " Permission.MODERATE_COMMENTS, False),\n", 86 | " 'Administrator': (0xff, False)\n", 87 | " }\n", 88 | " for r in roles:\n", 89 | " role = Role.select().where(Role.name == r).first()\n", 90 | " if role is None:\n", 91 | " role = Role(name=r)\n", 92 | " role.permissions = roles[r][0]\n", 93 | " role.default = roles[r][1]\n", 94 | " role.save()\n", 95 | "```\n", 96 | "\n", 97 | "`insert_roles()`函数先通过角色名查找现有的角色,然后再进行更新。只有当数据库中没有某个角色名时才会创建新角色对象。如果以后更新了角色列表,就可以执行更新操作。要想添加新角色,或者修改角色的权限,修改`roles`数组,再运行函数即可。\n", 98 | "\n", 99 | "使用 flask shell 会话将角色写入数据库:\n", 100 | "\n", 101 | "```python\n", 102 | "(flaskr_env3) $ flask shell\n", 103 | ">>> Role.insert_roles()\n", 104 | ">>> list(Role.select())\n", 105 | "[<Role 'User'>, <Role 'Moderator'>, <Role 'Administrator'>]\n", 106 | "```\n", 107 | "\n", 108 | "## 赋予角色\n", 109 | "\n", 110 | "用户在程序中注册账户时,即被赋予适当的角色。大多数用户在注册时赋予的角色都是 “用户”,即默认角色。管理员作为唯一的例外,应根据保存在设置变量`FLASKR_ADMIN`中 的电子邮件地址被赋予“管理员”角色。\n", 111 | "\n", 112 | "下面的代码用来定义默认的用户角色:\n", 113 | "\n", 114 | "```python\n", 115 | "# app/models.py\n", 116 | "class User(UserMixin, db.Model):\n", 117 | " # ...\n", 118 | " def __init__(self, **kwargs):\n", 119 | " super(User, self).__init__(**kwargs)\n", 120 | " if self.role is None:\n", 121 | " if self.email == current_app.config['FLASKR_ADMIN']:\n", 122 | " self.role = (Role.select()\n", 123 | " .where(Role.permissions == 0xff)\n", 124 | " .first())\n", 125 | " if self.role is None:\n", 126 | " self.role = Role.select().where(Role.default == True).first()\n", 127 | "```\n", 128 | "\n", 129 | "`User`类的构造函数首先调用基类的构造函数,如果创建基类对象后还没定义角色,则根据 电子邮件地址决定将其设为管理员还是默认角色。\n", 130 | "\n", 131 | "\n", 132 | "## 角色验证\n", 133 | "\n", 134 | "下面的代码用来检查用户是否有指定的权限:\n", 135 | "\n", 136 | "```python\n", 137 | "# app/models.py\n", 138 | "from flask_login import UserMixin, AnonymousUserMixin\n", 139 | "\n", 140 | "class User(UserMixin, db.Model):\n", 141 | " # ...\n", 142 | "\n", 143 | " def can(self, permissions):\n", 144 | " return (self.role is not None and\n", 145 | " (self.role.permissions & permissions) == permissions)\n", 146 | "\n", 147 | " def is_administrator(self):\n", 148 | " return self.can(Permission.ADMINISTER)\n", 149 | "\n", 150 | "\n", 151 | "class AnonymousUser(AnonymousUserMixin):\n", 152 | " def can(self, permissions):\n", 153 | " return False\n", 154 | "\n", 155 | " def is_administrator(self):\n", 156 | " return False\n", 157 | "\n", 158 | "\n", 159 | "login_manager.anonymous_user = AnonymousUser\n", 160 | "```\n", 161 | "\n", 162 | "`can()`方法在请求和赋予角色这两种权限之间进行 **位与操作** 。如果角色中包含请求的所有权限位,则返回`True`,表示允许用户执行此项操作。检查管理员权限的功能使用单独的方法`is_administrator()`实现。\n", 163 | "\n", 164 | "继承自 Flask-Login 中`AnonymousUserMixin`类的`AnonymousUser`类,也实现了`can()`方法和`is_administrator()`方法。通过将其设为用户未登录时`current_user`的值,程序不用先检查用户是否登录,也能自由调用`current_user.can()`和`current_user.is_administrator()`。\n", 165 | "\n", 166 | "如果想让视图函数只对具有特定权限的用户开放,可以使用自定义的装饰器:\n", 167 | "\n", 168 | "```python\n", 169 | "# app/decorators.py\n", 170 | "from functools import wraps\n", 171 | "\n", 172 | "from flask import abort\n", 173 | "from flask_login import current_user\n", 174 | "\n", 175 | "from .models import Permission\n", 176 | "\n", 177 | "\n", 178 | "def permission_required(permission):\n", 179 | " def decorator(f):\n", 180 | " @wraps(f)\n", 181 | " def decorated_function(*args, **kwargs):\n", 182 | " if not current_user.can(permission):\n", 183 | " abort(403)\n", 184 | " return f(*args, **kwargs)\n", 185 | " return decorated_function\n", 186 | " return decorator\n", 187 | "\n", 188 | "\n", 189 | "def admin_required(f):\n", 190 | " return permission_required(Permission.ADMINISTER)(f)\n", 191 | "```\n", 192 | "\n", 193 | "上面代码实现了两个装饰器,一个用来检查常规权限,一个专门用来检查管理员权限。如果用户不具有指定权限,则返回 **403** 错误码,即HTTP“禁止”错误。\n", 194 | "\n", 195 | "下面的代码演示如何使用上面的装饰器:\n", 196 | "\n", 197 | "```python\n", 198 | "from decorators import admin_required, permission_required\n", 199 | "from .models import Permission\n", 200 | "\n", 201 | "@main.route('/admin')\n", 202 | "@login_required\n", 203 | "@admin_required\n", 204 | "def for_admins_only():\n", 205 | " return \"For administrators!\"\n", 206 | "\n", 207 | "@main.route('/moderator')\n", 208 | "@login_required\n", 209 | "@permission_required(Permission.MODERATE_COMMENTS)\n", 210 | "def for_moderators_only():\n", 211 | " return \"For comment moderators!\"\n", 212 | "```\n", 213 | "\n", 214 | "为了方便在模板中检查权限时使用`Permission`类,避免每次调用`render_template()`时都多添加一个模板参数,可以使用 **上下文处理器** 。 **上下文处理器** 能让变量在所有模板中全局可访问。\n", 215 | "\n", 216 | "下面的代码用来把`Permission`类加入模板上下文:\n", 217 | "\n", 218 | "```python\n", 219 | "# app/main/__init__.py\n", 220 | "from ..models import Permission\n", 221 | "\n", 222 | "\n", 223 | "@main.app_context_processor\n", 224 | "def inject_permissions():\n", 225 | " return dict(Permission=Permission)\n", 226 | "```\n", 227 | "\n", 228 | "另外,新添加的角色和权限可在单元测试中进行测试。\n", 229 | "\n", 230 | "**🔖 执行`git checkout 9a`签出程序的这个版本。** ⚠️ 此版本包含一个数据库迁移。\n", 231 | "\n", 232 | "⚠️ 最好重新创建或更新开发数据库,为了赋予角色给在实现角色和权限之前创建的用户。" 233 | ] 234 | } 235 | ], 236 | "metadata": { 237 | "kernelspec": { 238 | "display_name": "Python 3", 239 | "name": "python3" 240 | }, 241 | "name": "9-user-roles.ipynb" 242 | }, 243 | "nbformat": 4, 244 | "nbformat_minor": 2 245 | } 246 | -------------------------------------------------------------------------------- /ch10/10-user-profiles.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": { 6 | "ein.tags": "worksheet-0", 7 | "slideshow": { 8 | "slide_type": "-" 9 | } 10 | }, 11 | "source": [ 12 | "# 用户资料\n", 13 | "\n", 14 | "为了显示用户在网站中的活动情况,或者便于分享,在本章我们将实现用户资料页面。\n", 15 | "\n", 16 | "\n", 17 | "## 资料信息\n", 18 | "\n", 19 | "下面是扩充了的`User`模型,增加了新的表示用户信息的字段:\n", 20 | "\n", 21 | "```python\n", 22 | "# app/models.py\n", 23 | "from datetime import datetime\n", 24 | "import peewee as pw\n", 25 | "\n", 26 | "\n", 27 | "class User(UserMixin, db.Model):\n", 28 | " # ...\n", 29 | " name = pw.CharField(64, null=True)\n", 30 | " location = pw.CharField(64, null=True)\n", 31 | " about_me = pw.TextField(null=True)\n", 32 | " member_since = pw.DateTimeField(default=datetime.utcnow, null=True)\n", 33 | " last_seen = pw.DateTimeField(default=datetime.utcnow, null=True)\n", 34 | "```\n", 35 | "\n", 36 | "新添加的字段保存用户的真实姓名、所在地、自我介绍、注册日期和最后访问日期。\n", 37 | "\n", 38 | "两个时间戳的默认值都是当前时间。`datetime.utcnow`后面没有`()`,因为`peewee.DateTimeField`的`default`参数可接受函数作为默认值,每次需要生成默认值时,其会调用指定的函数。\n", 39 | "\n", 40 | "`last_seen`字段创建时的初始值也是当前时间,但用户每次访问网站后,这个值都会被刷新。可以在`User`模型中添加一个方法完成这个操作:\n", 41 | "\n", 42 | "```python\n", 43 | "# app/models.py\n", 44 | "class User(UserMixin, db.Model):\n", 45 | " # ...\n", 46 | "\n", 47 | " def ping(self):\n", 48 | " self.last_seen = datetime.utcnow()\n", 49 | " self.save()\n", 50 | "```\n", 51 | "\n", 52 | "每次收到用户的请求时都需要调用`ping()`方法。使用蓝图中的`before_app_request`处理器 可以在每次请求前运行代码,更新已登录用户的访问时间。\n", 53 | "\n", 54 | "```python\n", 55 | "# app/auth/views.py\n", 56 | "@auth.before_app_request\n", 57 | "def before_request():\n", 58 | " if current_user.is_authenticated:\n", 59 | " current_user.ping()\n", 60 | "```\n", 61 | "\n", 62 | "\n", 63 | "## 用户资料页面\n", 64 | "\n", 65 | "下面定义用户资料页面的路由:\n", 66 | "\n", 67 | "```python\n", 68 | "# app/main/views.py\n", 69 | "import playhouse.flask_utils as futils\n", 70 | "\n", 71 | "from ..models import User\n", 72 | "\n", 73 | "@main.route('/user/<username>')\n", 74 | "def user(username):\n", 75 | " user_query = User.select()\n", 76 | " user = futils.get_object_or_404(user_query, (User.username == username))\n", 77 | " return render_template('user.html', user=user)\n", 78 | "```\n", 79 | "\n", 80 | "这个视图函数会在数据库中搜索URL中指定的用户名,如果找到,则渲染模板`user.html`,并把用户名作为参数传入模板。如果传入路由的用户名不存在,则返回 **404** 错误。\n", 81 | "\n", 82 | "用户资料页面的模板如下:\n", 83 | "\n", 84 | "```django\n", 85 | "{# app/templates/user.html #}\n", 86 | "{% block page_content %}\n", 87 | " <div class=\"page-header\">\n", 88 | " <h1>{{ user.username }}</h1>\n", 89 | " {% if user.name or user.location %}\n", 90 | " <p>\n", 91 | " {% if user.name %}{{ user.name }}{% endif %}\n", 92 | " {% if user.location %}\n", 93 | " From {{ user.location }}\n", 94 | " {% endif %}\n", 95 | " </p>\n", 96 | " {% endif %}\n", 97 | " {% if current_user.is_administrator() %}\n", 98 | " <p><a href=\"mailto:{{ user.email }}\">{{ user.email }}</a></p>\n", 99 | " {% endif %}\n", 100 | " {% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}\n", 101 | " <p>Member since {{ moment(user.member_since).format('L') }}.\n", 102 | " Last seen {{ moment(user.last_seen).fromNow() }}.</p>\n", 103 | " </div>\n", 104 | "{% endblock %}\n", 105 | "```\n", 106 | "\n", 107 | "为了使用户能够访问自己的资料页面,可以在导航条中添加一个链接:\n", 108 | "\n", 109 | "```django\n", 110 | "{# app/tempaltes/base.html #}\n", 111 | "{% if current_user.is_authenticated %}\n", 112 | " <li><a href=\"{{ url_for('main.user', username=current_user.username) }}\">Profile</a></li>\n", 113 | "{% endif %}\n", 114 | "```\n", 115 | "\n", 116 | "不应让未认证的用户看到资料页面的链接。\n", 117 | "\n", 118 | "另外,还需要创建数据库迁移和进行单元测试,相应的代码可在代码仓库中找到。\n", 119 | "\n", 120 | "**🔖 执行`git checkout 10a`签出程序的这个版本。**\n", 121 | "\n", 122 | "\n", 123 | "## 编辑用户资料\n", 124 | "\n", 125 | "用户资料的编辑分两种情况:\n", 126 | "\n", 127 | "1. 用户编辑自己的资料;\n", 128 | "2. 管理员应该能编辑任意用户的资料——包括编辑用户不能直接访问的`User`模型字段,例如用户角色。\n", 129 | "\n", 130 | "为实现以上两种编辑需求要创建两个不同的表单。\n", 131 | "\n", 132 | "\n", 133 | "### 用户级资料编辑\n", 134 | "\n", 135 | "普通用户的资料编辑表单如下:\n", 136 | "\n", 137 | "```python\n", 138 | "class EditProfileForm(FlaskForm):\n", 139 | " name = StringField('Real name', validators=[Length(0, 64)])\n", 140 | " location = StringField('Location', validators=[Length(0, 64)])\n", 141 | " about_me = TextAreaField('About me')\n", 142 | " submit = SubmitField('Submit')\n", 143 | "```\n", 144 | "\n", 145 | "此表单所有字段都是可选的,因此长度验证函数允许长度为零。\n", 146 | "\n", 147 | "显示这个表单的路由定义如下:\n", 148 | "\n", 149 | "```python\n", 150 | "# app/main/views.py\n", 151 | "@main.route('/edit-profile', methods=['GET', 'POST'])\n", 152 | "@login_required\n", 153 | "def edit_profile():\n", 154 | " form = EditProfileForm()\n", 155 | " if form.validate_on_submit():\n", 156 | " current_user.name = form.name.data\n", 157 | " current_user.location = form.location.data\n", 158 | " current_user.about_me = form.about_me.data\n", 159 | " current_user.save()\n", 160 | " flash('Your profile has been updated.')\n", 161 | " return redirect(url_for('.user', username=current_user.username))\n", 162 | " form.name.data = current_user.name\n", 163 | " form.location.data = current_user.location\n", 164 | " form.about_me.data = current_user.about_me\n", 165 | " return render_template('edit_profile.html', form=form)\n", 166 | "```\n", 167 | "\n", 168 | "视图函数通过赋值给`form.<field-name>.data`把所有字段设定了初始值,提交表单后,表单字段的`data`属性中保存有更新后的值,可以将其赋值给用户 对象中的各字段,然后再保存更新过的用户对象。\n", 169 | "\n", 170 | "接着添加资料编辑的链接:\n", 171 | "\n", 172 | "```django\n", 173 | "{# app/templates/user.html #}\n", 174 | "{% if user == current_user %}\n", 175 | " <a class=\"btn btn-default\" href=\"{{ url_for('.edit_profile') }}\">Edit Profile</a>\n", 176 | "{% endif %}\n", 177 | "```\n", 178 | "\n", 179 | "条件语句能确保只有当用户查看自己的资料页面时才显示这个链接。\n", 180 | "\n", 181 | "\n", 182 | "### 管理员级资料编辑\n", 183 | "\n", 184 | "相比普通用户的资料编辑表单,管理员在表单中还要能编辑用户的电子邮件、用户名、确认状态和角色。其表单示例如下:\n", 185 | "\n", 186 | "```python\n", 187 | "# app/main/forms.py\n", 188 | "class EditProfileAdminForm(FlaskForm):\n", 189 | " email = StringField('Email', validators=[Required(), Length(1, 64),\n", 190 | " Email()])\n", 191 | " username = StringField('Username', validators=[\n", 192 | " Required(), Length(1, 64), Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0,\n", 193 | " 'Usernames must have only letters, '\n", 194 | " 'numbers, dots or underscores')])\n", 195 | " confirmed = BooleanField('Confirmed')\n", 196 | " role = SelectField('Role', coerce=int)\n", 197 | " name = StringField('Real name', validators=[Length(0, 64)])\n", 198 | " location = StringField('Location', validators=[Length(0, 64)])\n", 199 | " about_me = TextAreaField('About me')\n", 200 | " submit = SubmitField('Submit')\n", 201 | "\n", 202 | " def __init__(self, user, *args, **kwargs):\n", 203 | " super(EditProfileAdminForm, self).__init__(*args, **kwargs)\n", 204 | " self.role.choices = [(role.id, role.name)\n", 205 | " for role in Role.select().order_by(Role.name)]\n", 206 | " self.user = user\n", 207 | "\n", 208 | " def validate_email(self, field):\n", 209 | " if (field.data != self.user.email and\n", 210 | " User.select().where(User.email == field.data).first()):\n", 211 | " raise ValidationError('Email already registered.')\n", 212 | "\n", 213 | " def validate_username(self, field):\n", 214 | " if (field.data != self.user.username and\n", 215 | " User.select().where(User.username == field.data).first()):\n", 216 | " raise ValidationError('Username already in use.')\n", 217 | "```\n", 218 | "\n", 219 | "WTForms使用`SelectField`包装HTML表单控件`<select>`,从而实现下拉列表,用来在这个表单中选择用户角色。`SelectField`实例必须在其`choices`属性中设置各选项。选项必须是一个由元组组成的列表,各元组都包含两个元素: 选项的标识符和显示在控件中的文本字符串。`choices`列表在表单的构造函数中设定,其值从`Role`模型中获取,使用一个查询按照角色名 的字母顺序排列所有角色。元组中的标识符是角色的`id`,因为其是个整数,所以 在`SelectField`构造函数中添加`coerce=int`参数,从而把字段的值转换为整数,而不使用默认的字符串。\n", 220 | "\n", 221 | "验证`email`和`username`字段时,首先要检查字段的值是否发生了变化,如果有变化,就要保证新值不和其他用户的相应字段值重复;如果字段值没有变化,则应该跳过验证。表单构造函数接收用户对象作为参数,并将其保存在成员变量中,随后自定义的验证方法要使用这个用户对象。\n", 222 | "\n", 223 | "管理员的资料编辑器路由定义如下:\n", 224 | "\n", 225 | "```python\n", 226 | "# app/main/views.py\n", 227 | "@main.route('/edit-profile/<int:id>', methods=['GET', 'POST'])\n", 228 | "@login_required\n", 229 | "@admin_required\n", 230 | "def edit_profile_admin(id):\n", 231 | " user_query = User.select()\n", 232 | " user = futils.get_object_or_404(user_query, (User.id == id))\n", 233 | " form = EditProfileAdminForm(user=user)\n", 234 | " if form.validate_on_submit():\n", 235 | " user.email = form.email.data\n", 236 | " user.username = form.username.data\n", 237 | " user.confirmed = form.confirmed.data\n", 238 | " user.role = Role.select().where(Role.id == form.role.data).first()\n", 239 | " user.name = form.name.data\n", 240 | " user.location = form.location.data\n", 241 | " user.about_me = form.about_me.data\n", 242 | " user.save()\n", 243 | " flash('The profile has been updated.')\n", 244 | " return redirect(url_for('.user', username=user.username))\n", 245 | " form.email.data = user.email\n", 246 | " form.username.data = user.username\n", 247 | " form.confirmed.data = user.confirmed\n", 248 | " form.role.data = user.role_id\n", 249 | " form.name.data = user.name\n", 250 | " form.location.data = user.location\n", 251 | " form.about_me.data = user.about_me\n", 252 | " return render_template('edit_profile.html', form=form, user=user)\n", 253 | "```\n", 254 | "\n", 255 | "⚠ 代码中对于选择用户角色的`SelectField`的处理,用户的角色根据表单字段的`data`属性提取`id`,并通过其值加在角色对象。\n", 256 | "\n", 257 | "接着添加管理员使用的资料编辑链接:\n", 258 | "\n", 259 | "```django\n", 260 | "{# app/templates/user.html #}\n", 261 | "{% if current_user.is_administrator() %}\n", 262 | " <a class=\"btn btn-danger\" href=\"{{ url_for('.edit_profile_admin', id=user.id) }}\">\n", 263 | " Edit Profile [Admin]\n", 264 | " </a>\n", 265 | "{% endif %}\n", 266 | "```\n", 267 | "\n", 268 | "条件语句确保只当登录用户为管理员时才显示按钮。\n", 269 | "\n", 270 | "**🔖 执行`git checkout 10b`签出程序的这个版本。**\n", 271 | "\n", 272 | "\n", 273 | "## 用户头像\n", 274 | "\n", 275 | "这一节我们着手考虑为用户添加头像,以改善页面的外观。可以考虑以下几种解决方案:\n", 276 | "\n", 277 | "1. 使用第三方头像服务,比如[Gravatar](https://gravatar.com)\n", 278 | "2. 允许用户上传头像图片\n", 279 | "3. 自动为程序中注册的用户生成唯一性头像\n", 280 | "\n", 281 | "我们在当前程序中加入结合第1种和第3种解决方案的实现。\n", 282 | "\n", 283 | "首先来了解一下 **Gravatar** 头像服务。\n", 284 | "\n", 285 | "Gravatar 是一个行业领先的头像服务,能把头像和电子邮件地址关联起来。用户先要到<https://gravatar.com>中注册账户,然后上传图片。生成头像的URL时,要计算电子邮件地址的 **MD5** 散列值:\n", 286 | "\n", 287 | "```python\n", 288 | "(flaskr_env3) $ python\n", 289 | ">>> import hashlib\n", 290 | ">>> hashlib.md5('john@example.com'.encode('utf-8')).hexdigest()\n", 291 | "'d4c74594d841139328695756648b6bd6'\n", 292 | "```\n", 293 | "\n", 294 | "生成的头像URL是在`http://www.gravatar.com/avatar/`或`https://secure.gravatar.com/avatar/`之后加上这个 MD5 散列值。例如,你在浏览器的地址栏中输入`http://www.gravatar.com/avatar/d4c74594d841139328695756648b6bd6`,就会看到电子邮件地址`john@example.com`对应的头像图片。\n", 295 | "\n", 296 | "如果这个电子邮件地址没有对应的头像,则会显示一个默认图片(或根据设置返回HTTP **404** 错误)。头像URL的查询字符串中可以包含多个参数以配置头像图片的特征。\n", 297 | "\n", 298 | "可设参数如下表所示:\n", 299 | "\n", 300 | "| 参数名 | 说明 |\n", 301 | "|:-----------------|:-----------------------------------------------------------------------------|\n", 302 | "| s (size) | 图片大小,单位为像素 |\n", 303 | "| r (rating) | 图片级别。可选值有“g”、“pg”、“r”和“x” |\n", 304 | "| d (default) | 没有注册Gravatar服务的用户使用的默认图片生成方式。可选值有:“404”,返回404错误;默认图片的URL;图片生成器 “mm”、“identicon”、“monsterid”、“wavatar”、“retro” 或 “blank” 之一 |\n", 305 | "| f (forcedefault) | 强制使用默认头像 |\n", 306 | "\n", 307 | "更详细的介绍请参考[Gravatar官方文档](https://gravatar.com/site/implement/images/)。\n", 308 | "\n", 309 | "接着来了解如何自动为用户生成唯一性头像。\n", 310 | "\n", 311 | "如果用户的电子邮件地址没有对应的Gravatar头像(用户没使用Gravatar头像服务),或者在 用户网络条件不佳无法访问Gravatar的情况,此时一种方案是自动为用户生成图像。\n", 312 | "\n", 313 | "这里直接使用了一个开源的库 **Identicon** ,其会根据散列值生成具有Github头像 风格的 **SVG** 格式的图片。\n", 314 | "\n", 315 | "我们将这部分的代码添加到`User`模型中,如下所示:\n", 316 | "\n", 317 | "```python\n", 318 | "# app/models.py\n", 319 | "import hashlib\n", 320 | "from flask import request\n", 321 | "\n", 322 | "import requests\n", 323 | "\n", 324 | "from utils.identicon import IdenticonSVG\n", 325 | "\n", 326 | "\n", 327 | "class User(UserMixin, db.Model):\n", 328 | " # ...\n", 329 | " def gravatar(self, size=100, default='404', rating='g'):\n", 330 | " if request.is_secure:\n", 331 | " url = 'https://secure.gravatar.com/avatar'\n", 332 | " else:\n", 333 | " url = 'http://www.gravatar.com/avatar'\n", 334 | "\n", 335 | " hash = hashlib.md5(self.email.lower().encode('utf-8')).hexdigest()\n", 336 | " gravatar_url = '{url}/{hash}?s={size}&d={default}&r={rating}'.format(\n", 337 | " url=url, hash=hash, size=size, default=default, rating=rating)\n", 338 | " return gravatar_url\n", 339 | "\n", 340 | " def avatar(self, size=100, **kwargs):\n", 341 | " gravatar_url = self.gravatar(size)\n", 342 | " r = requests.get(gravatar_url)\n", 343 | " if r.status_code == 404:\n", 344 | " hash = gravatar_url.split('/')[-1].split('?')[0]\n", 345 | " i = IdenticonSVG(hash, size=size, **kwargs)\n", 346 | " gravatar_url = 'data:image/svg+xml;text,{0}'.format(i.to_string(True))\n", 347 | "\n", 348 | " return gravatar_url\n", 349 | "```\n", 350 | "\n", 351 | "上面代码实现了两个实例方法`gravatar`和`avatar`,其中`gravatar`方法会根据URL基、 用户电子邮件地址的MD5散列值及相关参数来构建Gravatar请求URL,其会选择标准的或加密 的Gravatar URL基以匹配用户的安全需求。`avatar`方法会首先通过调用`gravatar`方法 来获得Gravatar请求URL,然后尝试使用`requests`库去获取用户头像,当返回 **404** 错误时 便使用 Identicon 库提供的`IdenticonSVG`类来生成 **SVG** 格式的头像图像并将其转换成 数据类型的URL以便直接嵌入到网页中<sup><a id=\"fnr.1\" class=\"footref\" href=\"#fn.1\">1</a></sup>。\n", 352 | "\n", 353 | "现在就可以在flask shell中生成头像的URL了:\n", 354 | "\n", 355 | "```python\n", 356 | "(flaskr_env3) $ flask shell\n", 357 | ">>> ctx = app.test_request_context('/')\n", 358 | ">>> ctx.push()\n", 359 | ">>> u = User(email='john@example.com')\n", 360 | ">>> u.gravatar()\n", 361 | "'http://www.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=100&d=404&r=g'\n", 362 | ">>> u.gravatar(size=256)\n", 363 | "'http://www.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=256&d=404&r=g'\n", 364 | ">>> u.avatar()\n", 365 | "\"data:image/svg+xml;text,<svg xmlns='http://www.w3.org/2000/svg' width='100' ...\"\n", 366 | ">>> ctx.pop()\n", 367 | "```\n", 368 | "\n", 369 | "因为`gravatar`方法要访问`request`请求对象,上面代码执行过程需要先激活请求上下文,否则会导致运行时错误,具体请参考[Diving into Context Locals](http://flask.pocoo.org/docs/0.12/reqcontext/#diving-into-context-locals)来了解细节。\n", 370 | "\n", 371 | "接着在用户资料页面中添加头像:\n", 372 | "\n", 373 | "```django\n", 374 | "{# app/templates/user.html #}\n", 375 | "...\n", 376 | "<img class=\"img-rounded profile-thumbnail\" src=\"{{ user.avatar(size=256) }}\">\n", 377 | "...\n", 378 | "```\n", 379 | "\n", 380 | "使用类似方式,可在基模板的导航条上添加一个已登录用户头像的小型缩略图。为了更好地调整页面中头像图片的显示格式,可使用一些自定义的 CSS 类。可以在代码仓库的`styles.css`文件中查看自定义的 CSS,`styles.css`文件保存在程序静态文件的文件夹中,而且要在`base.html`模板中引用。\n", 381 | "\n", 382 | "**🔖 执行`git checkout 10c`签出程序的这个版本。** ⚠ 签出的代码中`app/models.py`文件`avatar`方法存在BUG,请使用上面展示的代码进行临时修复。\n", 383 | "\n", 384 | "\n", 385 | "### 缓存电子邮件地址散列值\n", 386 | "\n", 387 | "为避免生成头像时每次都要生成MD5散列,减小计算量,可以将用户电子邮件地址的MD5散列缓存 在`User`模型中。\n", 388 | "\n", 389 | "下面是使用缓存的MD5散列值生成头像的代码:\n", 390 | "\n", 391 | "```python\n", 392 | "# app/models.py\n", 393 | "from requests.exceptions import ConnectionError, HTTPError\n", 394 | "\n", 395 | "\n", 396 | "class User(UserMixin, db.Model):\n", 397 | " # ...\n", 398 | " avatar_hash = pw.CharField(32, null=True)\n", 399 | "\n", 400 | " def __init__(self, **kwargs):\n", 401 | " if self.email is not None and self.avatar_hash is None:\n", 402 | " self.avatar_hash = hashlib.md5(\n", 403 | " self.email.lower().encode('utf-8')).hexdigest()\n", 404 | "\n", 405 | " def change_email(self, token):\n", 406 | " # ...\n", 407 | " self.email = new_email\n", 408 | " self.avatar_hash = hashlib.md5(\n", 409 | " self.email.lower().encode('utf-8')).hexdigest()\n", 410 | "\n", 411 | " def gravatar(self, size=100, default='404', rating='g'):\n", 412 | " if request.is_secure:\n", 413 | " url = 'https://secure.gravatar.com/avatar'\n", 414 | " else:\n", 415 | " url = 'http://www.gravatar.com/avatar'\n", 416 | " if not self.avatar_hash:\n", 417 | " self.avatar_hash = hashlib.md5(\n", 418 | " self.email.lower().encode('utf-8')).hexdigest()\n", 419 | " self.save()\n", 420 | " hash = self.avatar_hash\n", 421 | " gravatar_url = '{url}/{hash}?s={size}&d={default}&r={rating}'.format(\n", 422 | " url=url, hash=hash, size=size, default=default, rating=rating)\n", 423 | " return gravatar_url\n", 424 | "\n", 425 | " def avatar(self, size=100, **kwargs):\n", 426 | " gravatar_url = self.gravatar(size)\n", 427 | " try:\n", 428 | " r = requests.get(gravatar_url)\n", 429 | " r.raise_for_status()\n", 430 | " except (ConnectionError, HTTPError):\n", 431 | " i = IdenticonSVG(self.avatar_hash, size=size, **kwargs)\n", 432 | " gravatar_url = 'data:image/svg+xml;text,{0}'.format(i.to_string(True))\n", 433 | "\n", 434 | " return gravatar_url\n", 435 | "```\n", 436 | "\n", 437 | "模型初始化过程中会计算电子邮件的散列值,然后存入数据库,若用户更新了电子邮件地址,则会重新计算散列值。`gravatar()`方法会使用模型中保存的散列值; 如果模型中没有,就和之前一样计算电子邮件地址的散列值。另外,代码中还增加了网络错误处理。\n", 438 | "\n", 439 | "**🔖 执行`git checkout 10d`签出程序的这个版本。**\n", 440 | "\n", 441 | "## 脚注\n", 442 | "\n", 443 | "<sup><a id=\"fn.1\" class=\"footnum\" href=\"#fnr.1\">1</a></sup> 可参考[Data URLs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs)来了解细节。" 444 | ] 445 | } 446 | ], 447 | "metadata": { 448 | "kernelspec": { 449 | "display_name": "Python 3", 450 | "name": "python3" 451 | }, 452 | "name": "10-user-profiles.ipynb" 453 | }, 454 | "nbformat": 4, 455 | "nbformat_minor": 2 456 | } 457 | -------------------------------------------------------------------------------- /ch11/11-blog-posts.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": { 6 | "ein.tags": "worksheet-0", 7 | "slideshow": { 8 | "slide_type": "-" 9 | } 10 | }, 11 | "source": [ 12 | "# 博客文章\n", 13 | "\n", 14 | "本章要实现博客文章功能,允许用户阅读、撰写博客文章,我们关注的内容重点在 **模板重用** 、 **分页显示长列表** 以及 **富文本处理** 。\n", 15 | "\n", 16 | "\n", 17 | "## 提交显示博客文章\n", 18 | "\n", 19 | "先来定义文章模型`Post`:\n", 20 | "\n", 21 | "```python\n", 22 | "# app/models.py\n", 23 | "import peewee as pw\n", 24 | "\n", 25 | "\n", 26 | "class Post(db.Model):\n", 27 | " body = pw.TextField(null=True)\n", 28 | " timestamp = pw.DateTimeField(index=True, default=datetime.utcnow)\n", 29 | " author = pw.ForeignKeyField(User, related_name='posts', null=True)\n", 30 | "\n", 31 | " class Meta:\n", 32 | " db_table = 'posts'\n", 33 | "```\n", 34 | "\n", 35 | "博客文章包含正文、时间戳以及和`User`模型之间的一对多关系。`body`字段的定义类型是`TextField`,不限制长度。\n", 36 | "\n", 37 | "在程序的首页要显示一个表单,包含一个多行文本输入框,用于输入博客文章的内容,另外还有一个提交按钮:\n", 38 | "\n", 39 | "```python\n", 40 | "# app/main/forms.py\n", 41 | "class PostForm(FlaskForm):\n", 42 | " body = TextAreaField(\"What's on your mind?\", validators=[Required()])\n", 43 | " submit = SubmitField('Submit')\n", 44 | "```\n", 45 | "\n", 46 | "`index()`视图函数处理这个表单并把以前发布的博客文章列表传给模板:\n", 47 | "\n", 48 | "```python\n", 49 | "# app/main/views.py\n", 50 | "from .forms import PostForm\n", 51 | "\n", 52 | "\n", 53 | "@main.route('/', methods=['GET', 'POST'])\n", 54 | "def index():\n", 55 | " form = PostForm()\n", 56 | " if (current_user.is_authenticated and\n", 57 | " current_user.can(Permission.WRITE_ARTICLES) and\n", 58 | " form.validate_on_submit()):\n", 59 | " post = Post(body=form.body.data,\n", 60 | " author=current_user._get_current_object())\n", 61 | " post.save()\n", 62 | " return redirect(url_for('.index'))\n", 63 | " posts = Post.select().order_by(Post.timestamp.desc())\n", 64 | " return render_template('index.html', form=form, posts=posts)\n", 65 | "```\n", 66 | "\n", 67 | "这个视图函数把表单和完整的博客文章列表传给模板。文章列表按照时间戳进行降序排列。如果博客文章表单提交的数据能通过验证就创建一个新`Post`实例。在发布新文章之前,要检查当前用户是否有写文章的权限。\n", 68 | "\n", 69 | "新文章对象的`author`属性值为表达式`current_user._get_current_object()`。`current_user`由 Flask-Login 提供通过线程内的代理对象实现,需要真正的用户对象时 需调用`_get_current_object()`方法获取。\n", 70 | "\n", 71 | "接着对显示博客文章的首页模板进行调整:\n", 72 | "\n", 73 | "```django\n", 74 | "{# app/templates/index.html #}\n", 75 | "{% extends \"base.html\" %}\n", 76 | "{% import \"bootstrap/wtf.html\" as wtf %}\n", 77 | "\n", 78 | "...\n", 79 | "{% if current_user.is_authenticated %}\n", 80 | " <div>\n", 81 | " {% if current_user.can(Permission.WRITE_ARTICLES) %}\n", 82 | " {{ wtf.quick_form(form) }}\n", 83 | " {% endif %}\n", 84 | " </div>\n", 85 | "{% endif %}\n", 86 | "<ul class=\"posts\">\n", 87 | " {% for post in posts %}\n", 88 | " <li class=\"post\">\n", 89 | " <div class=\"post-thumbnail\">\n", 90 | " <a href=\"{{ url_for('.user', username=post.author.username) }}\">\n", 91 | " <img class=\"img-rounded profile-thumbnail\" src=\"{{ post.author.avatar(size=40) }}\">\n", 92 | " </a>\n", 93 | " </div>\n", 94 | " <div class=\"post-content\">\n", 95 | " <div class=\"post-date\">{{ moment(post.timestamp).fromNow() }}</div>\n", 96 | " <div class=\"post-author\"><a href=\"{{ url_for('.user', username=post.author.username) }}\">{{ post.author.username }}</a></div>\n", 97 | " <div class=\"post-body\">{{ post.body }}</div>\n", 98 | " </div>\n", 99 | " </li>\n", 100 | " {% endfor %}\n", 101 | "</ul>\n", 102 | "```\n", 103 | "\n", 104 | "如果用户所属角色没有`WRITE_ARTICLES`权限,则经`User.can()`方法检查后,不会显示博客文章表单。博客文章列表会通过 CSS 调整显示成媒体列表的样式。\n", 105 | "\n", 106 | "**🔖 执行`git checkout 11a`签出程序的这个版本。**\n", 107 | "\n", 108 | "\n", 109 | "## 在资料页显示博客文章\n", 110 | "\n", 111 | "将用户资料页改进一下,在上面显示该用户发布的博客文章列表。\n", 112 | "\n", 113 | "获取博客文章的资料页路由如下:\n", 114 | "\n", 115 | "```python\n", 116 | "# app/main/views.py\n", 117 | "@main.route('/user/<username>')\n", 118 | "def user(username):\n", 119 | " user_query = User.select()\n", 120 | " user = futils.get_object_or_404(user_query, (User.username == username))\n", 121 | " posts = user.posts.order_by(Post.timestamp.desc())\n", 122 | " return render_template('user.html', user=user, posts=posts)\n", 123 | "```\n", 124 | "\n", 125 | "用户发布的博客文章列表通过`User.posts`关系获取,`User.posts`返回的是查询对象,因此可在其上调用过滤器,例如`order_by()`。\n", 126 | "\n", 127 | "`user.html`模板也要使用一个HTML`<ul>`元素渲染博客文章。可以通过Jinja2 提供的`include()`指令来实现模板复用,将`index.html`和`user.html`中 相同的HTML片段移到新模板`_posts.html`中。\n", 128 | "\n", 129 | "```django\n", 130 | "{# app/templates/user.html #}\n", 131 | "...\n", 132 | "<h3>Posts by {{ user.username }}</h3>\n", 133 | "{% include '_posts.html' %}\n", 134 | "...\n", 135 | "```\n", 136 | "\n", 137 | "`_posts.html`模板名的下划线前缀是一种习惯用法,以区分独立模板和局部模板。\n", 138 | "\n", 139 | "**🔖 执行`git checkout 11b`签出程序的这个版本。**\n", 140 | "\n", 141 | "\n", 142 | "## 分页显示文章列表\n", 143 | "\n", 144 | "随着网站的发展,博客文章的数量会不断增多,如果要在首页和资料页显示全部文章,浏览速度会变慢且不符合实际需求。这一问题的一种解决方法是分页显示数据,进行 片段式渲染。\n", 145 | "\n", 146 | "\n", 147 | "### 创建虚拟博客文章数据\n", 148 | "\n", 149 | "若想实现博客文章分页,我们需要一个包含大量数据的测试数据库。这里我们使用 **ForgeryPy** 包来自动生成虚拟信息数据。\n", 150 | "\n", 151 | "严格来说,ForgeryPy 并不是这个程序的依赖,因为它只在开发过程中使用。为了区分生产环境的依赖和开发环境的依赖,可以把文件`requirements.in`换成`requirements`文件夹,在其中分别保存不同环境中的依赖。可以创建一个`dev.in`文件,列出开发过程中所需的依赖,再创建一个`prod.in`文件,列出生产环境所需的依赖。两个环境所需的大部分相同的依赖,可以创建一个`common.in`文件。这些依赖文件通过前面介绍的 pip-tools 工具进行管理,比如要生成`dev.txt`文件,可以通过 下面的命令获得:\n", 152 | "\n", 153 | "```sh\n", 154 | "pip-compile -o requirements/dev.txt requirements/common.in requirements/dev.in\n", 155 | "```\n", 156 | "\n", 157 | "下面的代码用来生成虚拟用户和博客文章:\n", 158 | "\n", 159 | "```python\n", 160 | "# app/models.py\n", 161 | "class User(UserMixin, db.Model):\n", 162 | " # ...\n", 163 | " @staticmethod\n", 164 | " def generate_fake(count=100):\n", 165 | " from random import seed\n", 166 | " import forgery_py\n", 167 | "\n", 168 | " seed()\n", 169 | " fake_data = []\n", 170 | " for i in range(count):\n", 171 | " fake_data.append(\n", 172 | " dict(email=forgery_py.internet.email_address(),\n", 173 | " username=forgery_py.internet.user_name(True),\n", 174 | " password_hash=generate_password_hash(\n", 175 | " forgery_py.lorem_ipsum.word()),\n", 176 | " confirmed=True,\n", 177 | " name=forgery_py.name.full_name(),\n", 178 | " location=forgery_py.address.city(),\n", 179 | " about_me=forgery_py.lorem_ipsum.sentence(),\n", 180 | " member_since=forgery_py.date.date(True)))\n", 181 | " for idx in range(0, len(fake_data), 10):\n", 182 | " with db.database.atomic():\n", 183 | " User.insert_many(fake_data[idx:idx+10]).execute()\n", 184 | "\n", 185 | "\n", 186 | "class Post(db.Model):\n", 187 | " # ...\n", 188 | " @staticmethod\n", 189 | " def generate_fake(count=100):\n", 190 | " from random import seed, randint\n", 191 | " import forgery_py\n", 192 | "\n", 193 | " seed()\n", 194 | " user_count = User.select().count()\n", 195 | " fake_data = []\n", 196 | " for i in range(count):\n", 197 | " u = User.select().offset(randint(0, user_count - 1)).first()\n", 198 | " fake_data.append(\n", 199 | " dict(body=forgery_py.lorem_ipsum.sentences(randint(1, 5)),\n", 200 | " timestamp=forgery_py.date.date(True),\n", 201 | " author=u))\n", 202 | " for idx in range(0, len(fake_data), 10):\n", 203 | " with db.database.atomic():\n", 204 | " Post.insert_many(fake_data[idx:idx+10]).execute()\n", 205 | "```\n", 206 | "\n", 207 | "这些虚拟对象的属性由 ForgeryPy 的随机信息生成器生成,其中的名字、电子邮件地址、 句子等属性看起来就像真的一样。\n", 208 | "\n", 209 | "用户的电子邮件地址和用户名必须是唯一的,但 ForgeryPy 随机生成这些信息,因此有重复的风险。这个异常的处理方式是使用数据库事务,每次提交10个数据,循环操作,如果中间发生异常则只影响当次事务,不会把用户写入数据库,因此生成的虚拟用户总数可能会比预期少。\n", 210 | "\n", 211 | "随机生成文章时要为每篇文章随机指定一个用户。使用`offset()`查询过滤器。这个过滤器会跳过参数中指定的记录数量。通过设定一个随机的偏移值,再调用`first()`方法,就能每次都获得一个不同的随机用户。\n", 212 | "\n", 213 | "使用新添加的方法,可以在 flask shell 中轻易生成大量虚拟用户和文章:\n", 214 | "\n", 215 | "```python\n", 216 | "(flaskr_env3) $ flask shell\n", 217 | ">>> User.generate_fake(50)\n", 218 | ">>> Post.generate_fake(50)\n", 219 | "```\n", 220 | "\n", 221 | "现在运行程序,会看到首页中显示了一个很长的随机博客文章列表。\n", 222 | "\n", 223 | "**🔖 执行`git checkout 11c`签出程序的这个版本。**\n", 224 | "\n", 225 | "\n", 226 | "### 分页渲染数据\n", 227 | "\n", 228 | "为了更好地处理分页数据以及后续添加分页导航,我们基于 Peewee 提供的`PaginatedQuery`并结合 Flask 实现了`Pagination`工具类,通过实例化这个类,我们就可以得到类似 Flask-SQLAlchemy 中的分页对象。下表是`Pagination`分页对象的属性:\n", 229 | "\n", 230 | "| 属性 | 说明 |\n", 231 | "|------------|------------------|\n", 232 | "| `items` | 当前页面中的记录 |\n", 233 | "| `query` | 分页的源查询 |\n", 234 | "| `page` | 当前页数 |\n", 235 | "| `prev_num` | 上一页的页数 |\n", 236 | "| `next_num` | 下一页的页数 |\n", 237 | "| `has_next` | 如果有下一页,返回 `True` |\n", 238 | "| `has_prev` | 如果有上一页,返回 `True` |\n", 239 | "| `pages` | 查询得到的总页数 |\n", 240 | "| `per_page` | 每页显示的记录数量 |\n", 241 | "| `total` | 查询返回的记录总数 |\n", 242 | "\n", 243 | "分页对象上还可调用一些方法:\n", 244 | "\n", 245 | "- `iter_pages(left_edge=2, left_current=2, right_current=5, right_edge=2)`\n", 246 | "\n", 247 | " 一个迭代器,返回一个在分页导航中显示的页数列表。这个列表的最左边显示`left_edge`页,当前页的左边显示`left_current`页,当前页的右边显示`right_current`页,最右边显示`right_edge`页。例如,在一个100页的列表中,当前页为第50页,使用默认配置,这个方法会返回以下页数:1、2、None、48、49、50、51、52、53、54、 55、None、99、100。`None`表示页数之间的间隔`next()`下一页的分页对象。\n", 248 | "\n", 249 | "- `prev()`\n", 250 | "\n", 251 | " 上一页的分页对象\n", 252 | "\n", 253 | "- `next()`\n", 254 | "\n", 255 | " 下一页的分页对象\n", 256 | "\n", 257 | "下面是使用`Pagination`类实现分页显示博客文章列表的视图函数:\n", 258 | "\n", 259 | "```python\n", 260 | "# app/main/views.py\n", 261 | "from utils.paginate_peewee import Pagination\n", 262 | "\n", 263 | "@main.route('/', methods=['GET', 'POST'])\n", 264 | "def index():\n", 265 | " form = PostForm()\n", 266 | " if (current_user.is_authenticated and\n", 267 | " current_user.can(Permission.WRITE_ARTICLES) and\n", 268 | " form.validate_on_submit()):\n", 269 | " post = Post(body=form.body.data,\n", 270 | " author=current_user._get_current_object())\n", 271 | " post.save()\n", 272 | " return redirect(url_for('.index'))\n", 273 | "\n", 274 | " pagination = Pagination(Post.select().order_by(Post.timestamp.desc()),\n", 275 | " current_app.config['FLASKR_POSTS_PER_PAGE'],\n", 276 | " check_bounds=False)\n", 277 | " posts = pagination.items\n", 278 | " return render_template('index.html', form=form, posts=posts,\n", 279 | " pagination=pagination)\n", 280 | "```\n", 281 | "\n", 282 | "渲染的页数将从请求的查询字符串`request.args`中获取,如果没有明确指定,则默认渲染第一页。`Pagination`第一个参数是一个查询对象(`SelectQuery`)或者模型类,`per_page`用来指定每页显示的记录数量,为了能够便利地配置每页显示的记录数量,参数`per_page`的值从程序的环境变量`FLASKR_POSTS_PER_PAGE`中读取。\n", 283 | "\n", 284 | "另一个可选参数为`check_bounds`,当其设为`True`时,如果请求的页数超出了范围,则会返回 **404** 错误;如果设为`False`(默认值),页数超出范围时会返回一个 查询对象(`SelectQuery`)。\n", 285 | "\n", 286 | "修改之后,首页中的文章列表只会显示有限数量的文章。若想查看第 2 页中的文章,要在浏览器地址栏中的 URL 后加上查询字符串`?page=2`。\n", 287 | "\n", 288 | "\n", 289 | "### 添加分页导航\n", 290 | "\n", 291 | "接着在模板中添加分页导航,下面是以Jinja2宏的形式实现的分页导航:\n", 292 | "\n", 293 | "```django\n", 294 | "{# app/templates/_macros.html #}\n", 295 | "{% macro pagination_widget(pagination, endpoint) %}\n", 296 | " <ul class=\"pagination\">\n", 297 | " <li{% if not pagination.has_prev %} class=\"disabled\"{% endif %}>\n", 298 | " <a href=\"{% if pagination.has_prev %}{{ url_for(endpoint, page=pagination.prev_num, **kwargs) }}{% else %}#{% endif %}\">\n", 299 | " «\n", 300 | " </a>\n", 301 | " </li>\n", 302 | " {% for p in pagination.iter_pages() %}\n", 303 | " {% if p %}\n", 304 | " {% if p == pagination.page %}\n", 305 | " <li class=\"active\">\n", 306 | " <a href=\"{{ url_for(endpoint, page = p, **kwargs) }}\">{{ p }}</a>\n", 307 | " </li>\n", 308 | " {% else %}\n", 309 | " <li>\n", 310 | " <a href=\"{{ url_for(endpoint, page = p, **kwargs) }}\">{{ p }}</a>\n", 311 | " </li>\n", 312 | " {% endif %}\n", 313 | " {% else %}\n", 314 | " <li class=\"disabled\"><a href=\"#\">…</a></li>\n", 315 | " {% endif %}\n", 316 | " {% endfor %}\n", 317 | " <li{% if not pagination.has_next %} class=\"disabled\"{% endif %}>\n", 318 | " <a href=\"{% if pagination.has_next %}{{ url_for(endpoint, page=pagination.next_num, **kwargs) }}{% else %}#{% endif %}\">\n", 319 | " »\n", 320 | " </a>\n", 321 | " </li>\n", 322 | " </ul>\n", 323 | "{% endmacro %}\n", 324 | "```\n", 325 | "\n", 326 | "这个宏创建了一个 Bootstrap 分页元素,即一个有特殊样式的无序列表,其中定义了下述页 面链接。\n", 327 | "\n", 328 | "- “上一页”链接。如果当前页是第一页,则为这个链接加上`disabled`类。\n", 329 | "- 分页对象的`iter_pages()`迭代器返回的所有页面链接。这些页面被渲染成具有明确页数的链接,页数在`url_for()`的参数中指定。当前显示的页面使用`active`CSS 类高亮显示。页数列表中的间隔使用省略号表示。\n", 330 | "- “下一页”链接。如果当前页是最后一页,则会禁用这个链接。\n", 331 | "\n", 332 | "Jinja2 宏的参数列表中不用加入`**kwargs`即可接收关键字参数。分页宏把接收到的所有关键字参数都传给了生成分页链接的`url_for()`方法。这种方式也可在路由中使用,例如包含一个动态部分的资料页。\n", 333 | "\n", 334 | "`pagination_widget`宏可放在`index.html`和`user.html`中的`_posts.html`模板后面。\n", 335 | "\n", 336 | "下面展示其在首页中的应用:\n", 337 | "\n", 338 | "```django\n", 339 | "{# app/templates/index.html #}\n", 340 | "{% extends \"base.html\" %}\n", 341 | "{% import \"bootstrap/wtf.html\" as wtf %}\n", 342 | "{% import \"_macros.html\" as macros %}\n", 343 | "\n", 344 | "...\n", 345 | "{% include '_posts.html' %}\n", 346 | "{% if pagination and pagination.pages > 1 %}\n", 347 | " <div class=\"pagination\">\n", 348 | " {{ macros.pagination_widget(pagination, '.index') }}\n", 349 | " </div>\n", 350 | "{% endif %}\n", 351 | "...\n", 352 | "```\n", 353 | "\n", 354 | "**🔖 执行`git checkout 11d`签出程序的这个版本。**\n", 355 | "\n", 356 | "\n", 357 | "## 富文本文章\n", 358 | "\n", 359 | "本节将对输入文章的多行文本输入框进行升级,让其支持[Markdown](http://daringfireball.net/projects/markdown/)语法,还要添加富文本文章的预览功能。\n", 360 | "\n", 361 | "要用到的包:\n", 362 | "\n", 363 | "- **[PageDown](https://github.com/StackExchange/pagedown):** 使用 JavaScript 实现的客户端 Markdown 到 HTML 的转换程序。\n", 364 | "- **[Flask-PageDown](https://github.com/miguelgrinberg/Flask-PageDown):** 为 Flask 包装的 PageDown,把 PageDown 集成到 Flask-WTF 表单中。\n", 365 | "- **[Markdown](https://pythonhosted.org/Markdown/):** 使用 Python 实现的服务器端 Markdown 到 HTML 的转换程序。\n", 366 | "- **[Bleach](https://bleach.readthedocs.io):** 使用 Python 实现的 HTML 清理器。\n", 367 | "\n", 368 | "将这些包添加到依赖包文件中并进行安装。\n", 369 | "\n", 370 | "\n", 371 | "### 预览Markdown\n", 372 | "\n", 373 | "Flask-PageDown 扩展定义了一个`PageDownField`类,其和 WTForms 中的`TextAreaField`接口 一致。先来初始化扩展:\n", 374 | "\n", 375 | "```python\n", 376 | "# app/__init__.py\n", 377 | "from flask_pagedown import PageDown\n", 378 | "# ...\n", 379 | "pagedown = Pagedown()\n", 380 | "# ...\n", 381 | "def create_app(config_name):\n", 382 | " # ...\n", 383 | " pagedown.init_app(app)\n", 384 | " # ...\n", 385 | "```\n", 386 | "\n", 387 | "修改`PostForm`表单中的`body`字段,启动Markdown的文章表单:\n", 388 | "\n", 389 | "```python\n", 390 | "# app/main/forms.py\n", 391 | "from flask_pagedown.fields import PageDownField\n", 392 | "\n", 393 | "\n", 394 | "class PostForm(FlaskForm):\n", 395 | " body = PageDownField(\"What's on your mind?\", validators=[Required()])\n", 396 | " submit = SubmitField('Submit')\n", 397 | "```\n", 398 | "\n", 399 | "Markdown 预览使用 PageDown 库生成,Flask-PageDown 提供了一个模板宏,支持从CDN加载所需文件。下面是模板声明:\n", 400 | "\n", 401 | "```django\n", 402 | "{# app/templates/index.html #}\n", 403 | "{% block scripts %}\n", 404 | "\t{{ super() }}\n", 405 | " {{ pagedown.include_pagedown() }}\n", 406 | "{% endblock %}\n", 407 | "```\n", 408 | "\n", 409 | "此外,还要为预览部分添加CSS样式,可参考代码仓库中的代码。\n", 410 | "\n", 411 | "**🔖 执行`git checkout 11e`签出程序的这个版本。** ⚠️ 安装程序所需的依赖。\n", 412 | "\n", 413 | "\n", 414 | "### 服务器端处理富文本\n", 415 | "\n", 416 | "提交表单后,POST请求只会发送纯Markdown文本,服务器上使用Markdown(使用Python编 写的Markdown到HTML转换程序)将其转换成HTML。得到HTML后,再使用Bleach进行清理,确保其中只包含几个允许使用的HTML标签。\n", 417 | "\n", 418 | "转换后的博客文章HTML代码缓存在`Post`模型的一个新字段中,在模板中可以直接调用。文章的Markdown源文本还要保存在数据库中,以防需要编辑。\n", 419 | "\n", 420 | "下面是在`Post`模型中处理Markdown文本:\n", 421 | "\n", 422 | "```python\n", 423 | "# app/decorators.py\n", 424 | "def require_instance(func):\n", 425 | " @wraps(func)\n", 426 | " def inner(self, *args, **kwargs):\n", 427 | " if not self._get_pk_value():\n", 428 | " raise TypeError(\n", 429 | " \"Can't call %s with a non-instance %s\" % (\n", 430 | " func.__name__, self.__class__.__name__))\n", 431 | " return func(self, *args, **kwargs)\n", 432 | " return inner\n", 433 | "```\n", 434 | "\n", 435 | "```python\n", 436 | "# app/models.py\n", 437 | "from markdown import markdown\n", 438 | "import bleach\n", 439 | "\n", 440 | "from .decorators import require_instance\n", 441 | "\n", 442 | "class Post(db.Model):\n", 443 | " # ...\n", 444 | " body_html = pw.TextField(null=True)\n", 445 | "\n", 446 | " # ...\n", 447 | " @require_instance\n", 448 | " def update_body_html(self):\n", 449 | " allowed_tags = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code',\n", 450 | " 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul',\n", 451 | " 'h1', 'h2', 'h3', 'p']\n", 452 | " self.__class__.update(body_html=bleach.linkify(bleach.clean(\n", 453 | " markdown(self.body, output_format='html'),\n", 454 | " tags=allowed_tags, strip=True))).where(self._pk_expr()).execute()\n", 455 | "```\n", 456 | "\n", 457 | "上面的代码中使用了一个装饰器`require_instance`,用来确保更新`body_html`字段之前 相应的`Post`模型实例已经创建并存储在数据库中。\n", 458 | "\n", 459 | "将Markdown转换成HTML的过程分三步完成。\n", 460 | "\n", 461 | "1. `markdown()`函数初步把Markdown文本转换成 HTML。\n", 462 | "\n", 463 | "2. 把得到的结果和允许使用的 HTML 标签列表传给`clean()`函数,删除所有不在白名单中的标签。\n", 464 | "\n", 465 | "3. 最后一步通过`bleach.linkify`把纯文本中的URL转换成适当的`<a>`链接<sup><a id=\"fnr.1\" class=\"footref\" href=\"#fn.1\">1</a></sup>。\n", 466 | "\n", 467 | "当插入或更新`body`字段时,先要保存实例,然后通过调用实例的`update_body_html`方法更新`body_html`字段。\n", 468 | "\n", 469 | "接着修改在模板中使用文章HTML内容的模板:\n", 470 | "\n", 471 | "```django\n", 472 | "{# app/templates/_posts.html #}\n", 473 | "...\n", 474 | "<div class=\"post-body\">\n", 475 | " {% if post.body_html %}\n", 476 | " {{ post.body_html | safe }}\n", 477 | " {% else %}\n", 478 | " {{ post.body }}\n", 479 | " {% endif %}\n", 480 | "</div>\n", 481 | "...\n", 482 | "```\n", 483 | "\n", 484 | "渲染HTML格式内容时使用`| safe`后缀,其目的是告诉Jinja2不要转义HTML元素。\n", 485 | "\n", 486 | "**🔖 执行`git checkout 11f`签出程序的这个版本。** ⚠️ 此版本包含数据库迁移和安装依赖包。\n", 487 | "\n", 488 | "\n", 489 | "## 文章固定链接\n", 490 | "\n", 491 | "为每篇文章添加一个专页,使用唯一的 URL 引用。下面是支持固定链接功能的路由和视图函数:\n", 492 | "\n", 493 | "```python\n", 494 | "# app/main/views.py\n", 495 | "@main.route('/post/<int:id>')\n", 496 | "def post(id):\n", 497 | " post_query = Post.select()\n", 498 | " post = futils.get_object_or_404(post_query, (Post.id == id))\n", 499 | " return render_template('post.html', posts=[post])\n", 500 | "```\n", 501 | "\n", 502 | "博客文章的URL使用插入数据库时分配的唯一`id`字段构建。\n", 503 | "\n", 504 | "⚠️ `post.html`模板接收一个列表作为参数,这个列表就是要渲染的文章。这里必须要传入列表,因为要在这个页面中复用`_posts.html`模板。\n", 505 | "\n", 506 | "固定链接添加到通用模板`_posts.html`中,显示在文章下方:\n", 507 | "\n", 508 | "```django\n", 509 | "{# app/templates/_posts.html #}\n", 510 | "\n", 511 | "...\n", 512 | "<div class=\"post-body\">\n", 513 | " {% if post.body_html %}\n", 514 | " {{ post.body_html | safe }}\n", 515 | " {% else %}\n", 516 | " {{ post.body }}\n", 517 | " {% endif %}\n", 518 | "</div>\n", 519 | "<div class=\"post-footer\">\n", 520 | " <a class=\"label label-info\" href=\"{{ url_for('.post', id=post.id) }}\">Permalink</a>\n", 521 | "</div>\n", 522 | "...\n", 523 | "```\n", 524 | "\n", 525 | "渲染固定链接页面的`post.html`模板:\n", 526 | "\n", 527 | "```django\n", 528 | "{# app/templates/post.html #}\n", 529 | "{% extends \"base.html\" %}\n", 530 | "\n", 531 | "{% block title %}Flaskr - Post{% endblock %}\n", 532 | "\n", 533 | "{% block page_content %}\n", 534 | " {% include '_posts.html' %}\n", 535 | "{% endblock %}\n", 536 | "```\n", 537 | "\n", 538 | "**🔖 执行`git checkout 11g`签出程序的这个版本。**\n", 539 | "\n", 540 | "\n", 541 | "## 编辑文章\n", 542 | "\n", 543 | "文章编辑显示在单独的页面中,其上部会显示文章的当前版本,下面跟着一个Markdown编辑器,用于修改Markdown源,页面下部还会显示一个编辑后的文章预览。\n", 544 | "\n", 545 | "下面是编辑文章的模板:\n", 546 | "\n", 547 | "```django\n", 548 | "{# app/templates/edit_post.html #}\n", 549 | "{% extends \"base.html\" %}\n", 550 | "{% import \"bootstrap/wtf.html\" as wtf %}\n", 551 | "\n", 552 | "{% block title %}Flaskr - Edit Post{% endblock %}\n", 553 | "\n", 554 | "{% block page_content %}\n", 555 | " <div class=\"page-header\">\n", 556 | " <h1>Edit Post</h1>\n", 557 | " </div>\n", 558 | " <div>\n", 559 | " {{ wtf.quick_form(form) }}\n", 560 | " </div>\n", 561 | "{% endblock %}\n", 562 | "\n", 563 | "{% block scripts %}\n", 564 | " {{ super() }}\n", 565 | " {{ pagedown.include_pagedown() }}\n", 566 | "{% endblock %}\n", 567 | "```\n", 568 | "\n", 569 | "编辑文章的路由如下:\n", 570 | "\n", 571 | "```python\n", 572 | "# app/main/views.py\n", 573 | "@main.route('/edit/<int:id>', methods=['GET', 'POST'])\n", 574 | "@login_required\n", 575 | "def edit(id):\n", 576 | " post_query = Post.select()\n", 577 | " post = futils.get_object_or_404(post_query, (Post.id == id))\n", 578 | " if current_user != post.author and \\\n", 579 | " not current_user.can(Permission.ADMINISTER):\n", 580 | " abort(403)\n", 581 | " form = PostForm()\n", 582 | " if form.validate_on_submit():\n", 583 | " post.body = form.body.data\n", 584 | " post.save()\n", 585 | " post.update_body_html()\n", 586 | " flash('The post has been updated.')\n", 587 | " return redirect(url_for('.post', id=post.id))\n", 588 | " form.body.data = post.body\n", 589 | " return render_template('edit_post.html', form=form)\n", 590 | "```\n", 591 | "\n", 592 | "这个视图函数只允许博客文章的作者编辑文章,但管理员能编辑所有用户的文章。如果用户试图编辑其他用户的文章,视图函数会返回 **403** 错误。这里使用的`PostForm`表单类和首页中使用的是同一个。\n", 593 | "\n", 594 | "另外,为了功能完整,可在每篇博客文章的下面、固定链接的旁边添加一个指向编辑页面的链接。\n", 595 | "\n", 596 | "**🔖 执行`git checkout 11h`签出程序的这个版本。**\n", 597 | "\n", 598 | "## 脚注\n", 599 | "\n", 600 | "<sup><a id=\"fn.1\" class=\"footnum\" href=\"#fnr.1\">1</a></sup> Markdown规范没有为自动生成链接提供官方支持,PageDown以扩展的形式实现了这个功能,在服务器上要调用 `linkify()` 函数。" 601 | ] 602 | } 603 | ], 604 | "metadata": { 605 | "kernelspec": { 606 | "display_name": "Python 3", 607 | "name": "python3" 608 | }, 609 | "name": "11-blog-posts.ipynb" 610 | }, 611 | "nbformat": 4, 612 | "nbformat_minor": 2 613 | } 614 | -------------------------------------------------------------------------------- /ch12/12-followers.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": { 6 | "ein.tags": "worksheet-0", 7 | "slideshow": { 8 | "slide_type": "-" 9 | } 10 | }, 11 | "source": [ 12 | "# 关注用户\n", 13 | "\n", 14 | "本章将实现关注功能,让用户“关注”其他用户,并在首页只显示所关注用户发布的博客文章列表。\n", 15 | "\n", 16 | "\n", 17 | "## 高级自引用多对多关系\n", 18 | "\n", 19 | "在数据库中表示用户之间的关注可使用自引用多对多关系,此外还要存储用户关注另一个用户的日期, 以便按照时间顺序列出所有关注者。下面的代码定义了用户关注使用的`Follow`模型:\n", 20 | "\n", 21 | "```python\n", 22 | "# app/models.py\n", 23 | "import peewee as pw\n", 24 | "\n", 25 | "\n", 26 | "class Follow(db.Model):\n", 27 | " follower = pw.ForeignKeyField(User, related_name='followed',\n", 28 | " on_delete='CASCADE')\n", 29 | " followed = pw.ForeignKeyField(User, related_name='followers',\n", 30 | " on_delete='CASCADE')\n", 31 | " timestamp = pw.DateTimeField(default=datetime.utcnow)\n", 32 | "\n", 33 | " class Meta:\n", 34 | " db_table = 'follows'\n", 35 | " indexes = (\n", 36 | " (('follower', 'followed'), True),\n", 37 | " )\n", 38 | "```\n", 39 | "\n", 40 | "其中`follower`字段表示关注者,`followed`字段表示被关注者。这两个字段通过`User`模型 进行反向引用,将`Follow`模型中的多对多关系的左右两侧拆分成两个基本的一对多关系, 左侧表示的一对多关系把用户和`follows`表中的一组记录联系起来,用户是关注者。右侧表示的一对多关系把用户和`follows`表中的一组记录联系起来,用户是被关注者。例如, 如果某个用户关注了100个用户,调用`user.followed`后会返回一个可迭代查询对象 (`SelectQuery`) ,每一个迭代实例的`follower`和`followed`回引属性都指向相应的用户。\n", 41 | "\n", 42 | "删除对象时,默认的层叠行为是把对象联接的所有相关对象的外键设为空值。但在关联表中,删除记录后正确的行为应该是把指向该记录的实体也删除, 设置参数 `on_delete='CASCADE'` 能有效销毁联接。\n", 43 | "\n", 44 | "程序现在要处理两个一对多关系,以便实现多对多关系。在`User`模型中定义用于控制关系 的4个新方法:\n", 45 | "\n", 46 | "```python\n", 47 | "# app/models.py\n", 48 | "\n", 49 | "class User(UserMixin, db.Model):\n", 50 | " # ...\n", 51 | " def follow(self, user):\n", 52 | " if not self.is_following(user):\n", 53 | " f = Follow(follower=self, followed=user)\n", 54 | " f.save()\n", 55 | "\n", 56 | " def unfollow(self, user):\n", 57 | " f = self.followed.where(Follow.followed == user.id).first()\n", 58 | " if f:\n", 59 | " f.delete_instance()\n", 60 | "\n", 61 | " def is_following(self, user):\n", 62 | " return self.followed.where(\n", 63 | " Follow.followed == user.id).first() is not None\n", 64 | "\n", 65 | " def is_followed_by(self, user):\n", 66 | " return self.followers.where(\n", 67 | " Follow.follower == user.id).first() is not None\n", 68 | "```\n", 69 | "\n", 70 | "`follow()`方法手动把`Follow`实例插入关联表,从而把关注者和被关注者联接起来, 并让程序设定自定义字段的值。`unfollow()`方法使用`followed`关系找到联接用户和被关注用户的`Follow`实例。若要销毁这两个用户之间的联接,只需删除这个`Follow`对象即可。`is_following()`方法和`is_followed_ by()`方法分别在左右两边的一对多关系中搜索指定用户,如果找到了就返回`True`。\n", 71 | "\n", 72 | "为了开启Sqlite3的外键支持,需要在配置文件中添加Peewee的数据库连接参数:\n", 73 | "\n", 74 | "```python\n", 75 | "# config.py\n", 76 | "class Config(object):\n", 77 | " # ...\n", 78 | " PEEWEE_CONNECTION_PARAMS = {\n", 79 | " 'pragmas': [('foreign_keys', 'on')]\n", 80 | " }\n", 81 | "```\n", 82 | "\n", 83 | "另外,可以在代码仓库找到对于这个数据库关系的单元测试。\n", 84 | "\n", 85 | "**🔖 执行`git checkout 12a`签出程序的这个版本。** ⚠️ 此版本包含数据库迁移。\n", 86 | "\n", 87 | "\n", 88 | "## 显示关注者\n", 89 | "\n", 90 | "用户查看一个尚未关注用户的资料页,页面中应显示一个“Follow”(关注)按钮, 如果查看已关注用户的资料页则显示“Unfollow”(取消关注)按钮。\n", 91 | "\n", 92 | "页面中最好还能显示出关注者和被关注者的数量,再列出关注和被关注的用户列表, 并在相应的用户资料页中显示“Follows You”(关注了你)的提示。\n", 93 | "\n", 94 | "下面是在用户资料页上部添加关注信息的模板:\n", 95 | "\n", 96 | "```django\n", 97 | "{# app/templates/user.html #}\n", 98 | "{% if current_user.can(Permission.FOLLOW) and user != current_user %}\n", 99 | " {% if not current_user.is_following(user) %}\n", 100 | " <a href=\"{{ url_for('.follow', username=user.username) }}\"\n", 101 | " class=\"btn btn-primary btn-sm\">Follow</a>\n", 102 | " {% else %}\n", 103 | " <a href=\"{{ url_for('.unfollow', username=user.username) }}\"\n", 104 | " class=\"btn btn-danger btn-sm\">Unfollow</a>\n", 105 | " {% endif %}\n", 106 | "{% endif %}\n", 107 | "\n", 108 | "<a class=\"btn btn-info btn-sm\" href=\"{{ url_for('.followers', username=user.username) }}\">\n", 109 | " Followers <span class=\"badge\">{{ user.followers.count() }}</span>\n", 110 | "</a>\n", 111 | "<a class=\"btn btn-warning btn-sm\" href=\"{{ url_for('.followed_by', username=user.username) }}\">\n", 112 | " Following <span class=\"badge\">{{ user.followed.count() }}</span>\n", 113 | "</a>\n", 114 | "{% if current_user.is_authenticated and user != current_user and user.is_following(current_user) %}\n", 115 | " | <span class=\"label label-success\">Follows you</span>\n", 116 | "{% endif %}\n", 117 | "```\n", 118 | "\n", 119 | "模板用到了4个新端点,分别对应相应的路由:\n", 120 | "\n", 121 | "1. `/follow/<username>`\n", 122 | "\n", 123 | " ```python\n", 124 | " # app/main/views.py\n", 125 | " @main.route('/follow/<username>')\n", 126 | " @login_required\n", 127 | " @permission_required(Permission.FOLLOW)\n", 128 | " def follow(username):\n", 129 | " user = User.select().where(User.username == username).first()\n", 130 | " if user is None:\n", 131 | " flash('Invalid user.')\n", 132 | " return redirect(url_for('.index'))\n", 133 | " if current_user.is_following(user):\n", 134 | " flash('You are already following this user.')\n", 135 | " return redirect(url_for('.user', username=username))\n", 136 | " current_user.follow(user)\n", 137 | " flash('You are now following %s.' % username)\n", 138 | " return redirect(url_for('.user', username=username))\n", 139 | " ```\n", 140 | "\n", 141 | " 这个视图函数先加载请求的用户,确保用户存在且当前登录用户还没有关注这个用户,然 后调用`User`模型中定义的辅助方法`follow()`,用以联接两个用户。\n", 142 | "\n", 143 | "2. `/unfollow/<username>`\n", 144 | "\n", 145 | " 与上面`/follow/<username>`路由类似。\n", 146 | "\n", 147 | "3. `/followers/<username>`\n", 148 | "\n", 149 | " ```python\n", 150 | " # app/main/views.py\n", 151 | " @main.route('/followers/<username>')\n", 152 | " def followers(username):\n", 153 | " user = User.select().where(User.username == username).first()\n", 154 | " if user is None:\n", 155 | " flash('Invalid user.')\n", 156 | " return redirect(url_for('.index'))\n", 157 | " page = request.args.get('page', 1, type=int)\n", 158 | " pagination = Pagination(Follow.followers_of(user),\n", 159 | " current_app.config['FLASKR_FOLLOWERS_PER_PAGE'],\n", 160 | " page,\n", 161 | " check_bounds=False)\n", 162 | " follows = [{'user': item.follower, 'timestamp': item.timestamp}\n", 163 | " for item in pagination.items]\n", 164 | " return render_template('followers.html', user=user,\n", 165 | " title='Followers of',\n", 166 | " endpoint='.followers', pagination=pagination,\n", 167 | " follows=follows)\n", 168 | " ```\n", 169 | "\n", 170 | " 这个函数加载并验证请求的用户,然后使用分页显示该用户的 followers 关系。\n", 171 | "\n", 172 | "4. `/followed-by/<username>`\n", 173 | "\n", 174 | " 与`/followers/<username>`路由类似。\n", 175 | "\n", 176 | "其中`Follow`模型类方法`followers_of`和`followed_by`的实现如下:\n", 177 | "\n", 178 | "```python\n", 179 | "# app/models.py\n", 180 | "class Follow(db.Model):\n", 181 | " # ...\n", 182 | " @classmethod\n", 183 | " def followers_of(cls, user):\n", 184 | " \"\"\"Followers of user.\"\"\"\n", 185 | " return (cls.select(cls, User)\n", 186 | " .join(User, on=cls.follower)\n", 187 | " .where(cls.followed == user))\n", 188 | "\n", 189 | " @classmethod\n", 190 | " def followed_by(cls, user):\n", 191 | " \"\"\"Followed by user.\"\"\"\n", 192 | " return (cls.select(cls, User)\n", 193 | " .join(User, on=cls.followed)\n", 194 | " .where(Follow.follower == user))\n", 195 | "```\n", 196 | "\n", 197 | "渲染关注者列表的`followers.html`模板能用来渲染关注的用户列表和被关注的用户列表。模板接收的参数包括用户对象、分页链接使用的端点、分页对象和查询结果列表。\n", 198 | "\n", 199 | "**🔖 执行`git checkout 12b`签出程序的这个版本。**\n", 200 | "\n", 201 | "\n", 202 | "## 查询所关注用户的文章\n", 203 | "\n", 204 | "在程序首页显示用户所关注用户发布的所有文章。这里使用了数据库联结查询,为了提升效率,避免 [N+1查询问题](http://docs.peewee-orm.com/en/latest/peewee/querying.html#avoiding-n-1-queries),同时查询`Post`和`User`模型,然后进行联结操作,最后进行过滤操作。下面是获取所关注用户的文章的代码示例:\n", 205 | "\n", 206 | "```python\n", 207 | "# app/models.py\n", 208 | "class User(UserMixin, db.Model):\n", 209 | " # ...\n", 210 | " @property\n", 211 | " def followed_posts(self):\n", 212 | " return (Post.select(Post, self.__class__)\n", 213 | " .join(Follow, on=(Post.author == Follow.followed))\n", 214 | " .join(self.__class__, on=(Post.author == self.__class__.id))\n", 215 | " .where(Follow.follower == self))\n", 216 | "```\n", 217 | "\n", 218 | "`followed_posts()`方法定义为属性,调用时无需加`()`。\n", 219 | "\n", 220 | "**🔖 执行`git checkout 12c`签出程序的这个版本。**\n", 221 | "\n", 222 | "\n", 223 | "## 显示所关注用户文章\n", 224 | "\n", 225 | "现在,用户可以选择在首页显示所有用户的博客文章还是只显示所关注用户的文章。\n", 226 | "\n", 227 | "显示所有博客文章或只显示所关注用户的文章的代码如下:\n", 228 | "\n", 229 | "```python\n", 230 | "# app/main/views.py\n", 231 | "@main.route('/', methods=['GET', 'POST'])\n", 232 | "def index():\n", 233 | " # ...\n", 234 | " show_followed = False\n", 235 | " if current_user.is_authenticated:\n", 236 | " show_followed = bool(request.cookies.get('show_followed', ''))\n", 237 | " if show_followed:\n", 238 | " query = current_user.followed_posts\n", 239 | " else:\n", 240 | " query = Post.timeline()\n", 241 | " pagination = Pagination(query,\n", 242 | " current_app.config['FLASKR_POSTS_PER_PAGE'],\n", 243 | " check_bounds=False)\n", 244 | " posts = pagination.items\n", 245 | " return render_template('index.html', form=form, posts=posts,\n", 246 | " show_followed=show_followed, pagination=pagination)\n", 247 | "```\n", 248 | "\n", 249 | "显示所关注用户文章的选项存储在cookie的`show_followed`字段中, 如果其值为非空字符串,则表示只显示所关注用户的文章。cookie以`request.cookies`字典的形式存储在请求对象中。这个cookie的值会转换成布尔值,根据得到的值设定本地变量`query`的值。`query`的值决定最终获取所有博客文章的查询,或是获取过滤后的博客文章查询。\n", 250 | "\n", 251 | "`show_followed`cookie在两个新路由中设定:\n", 252 | "\n", 253 | "```python\n", 254 | "# app/main/views.py\n", 255 | "@main.route('/all')\n", 256 | "@login_required\n", 257 | "def show_all():\n", 258 | " resp = make_response(redirect(url_for('.index')))\n", 259 | " resp.set_cookie('show_followed', '', max_age=30*24*60*60)\n", 260 | " return resp\n", 261 | "\n", 262 | "\n", 263 | "@main.route('/followed')\n", 264 | "@login_required\n", 265 | "def show_followed():\n", 266 | " resp = make_response(redirect(url_for('.index')))\n", 267 | " resp.set_cookie('show_followed', '1', max_age=30*24*60*60)\n", 268 | " return resp\n", 269 | "```\n", 270 | "\n", 271 | "指向这两个路由的链接添加在首页模板中。点击这两个链接后会为`show_followed`cookie设定适当的值,然后重定向到首页。cookie只能在响应对象中设置,要使用`make_response()`方法创建响应对象。\n", 272 | "\n", 273 | "`set_cookie()`函数的前两个参数分别是cookie名和值。可选的`max_age`参数设置 cookie的过期时间,单位为秒。如果不指定参数`max_age`,浏览器关闭后cookie就会过期。在本例中,过期时间为30天。\n", 274 | "\n", 275 | "接下来需要对模板进行改动,在页面上部添加两个导航选项卡,分别调用`/all`和`/followed`路由,并在会话中设定正确的值。\n", 276 | "\n", 277 | "```django\n", 278 | "{# app/templates/index.html #}\n", 279 | "...\n", 280 | "<div class=\"post-tabs\">\n", 281 | " <ul class=\"nav nav-tabs\">\n", 282 | " <li{% if not show_followed %} class=\"active\"{% endif %}>\n", 283 | " <a href=\"{{ url_for('.show_all') }}\">All</a>\n", 284 | " </li>\n", 285 | " {% if current_user.is_authenticated %}\n", 286 | " <li{% if show_followed %} class=\"active\"{% endif %}>\n", 287 | " <a href=\"{{ url_for('.show_followed') }}\">Followers</a>\n", 288 | " </li>\n", 289 | " {% endif %}\n", 290 | " </ul>\n", 291 | " {% include '_posts.html' %}\n", 292 | "</div>\n", 293 | "...\n", 294 | "```\n", 295 | "\n", 296 | "**🔖 执行`git checkout 12d`签出程序的这个版本。**\n", 297 | "\n", 298 | "最后,为了切换所关注用户文章列表时能显示自己的文章,可在构建用户时把用户设为自己的关注者:\n", 299 | "\n", 300 | "```python\n", 301 | "# app/models.py\n", 302 | "class User(UserMixin, db.Model):\n", 303 | " # ...\n", 304 | " @staticmethod\n", 305 | " def add_self_follows():\n", 306 | " for user in User.select():\n", 307 | " if not user.is_following(user):\n", 308 | " user.follow(user)\n", 309 | " # ...\n", 310 | " def save(self, *args, **kwargs):\n", 311 | " super(self.__class__, self).save(*args, **kwargs)\n", 312 | " if not self.is_following(self):\n", 313 | " self.follow(self)\n", 314 | "```\n", 315 | "\n", 316 | "通过覆盖基类的`save()`方法,用户实例在保存时会检查自己是否是自己的关注者, 如果不是,将关注自己。\n", 317 | "\n", 318 | "通过`add_self_follows`方法,可以更新数据库中现有的用户关注自身。\n", 319 | "\n", 320 | "可通过在 flask shell中运行这个函数来更新数据库:\n", 321 | "\n", 322 | "```python\n", 323 | "(flaskr_env3) $ flask shell\n", 324 | ">>> User.add_self_follows()\n", 325 | "```\n", 326 | "\n", 327 | "用户关注自己这一功能的实现有一些副作用。因为用户的自关注链接,用户资料页显示的关注者 和被关注者的数量都增加了1个。为了显示准确,这些数字要减去1,直接渲染`{{ user.followers.count() - 1 }}`和`{{ user.followed.count() - 1 }}`即可。还要通过在模板中使用条件语句调整关注用户和被关注用户的列表,不显示自己。\n", 328 | "\n", 329 | "**🔖 执行`git checkout 12e`签出程序的这个版本。**" 330 | ] 331 | } 332 | ], 333 | "metadata": { 334 | "kernelspec": { 335 | "display_name": "Python 3", 336 | "name": "python3" 337 | }, 338 | "name": "12-followers.ipynb" 339 | }, 340 | "nbformat": 4, 341 | "nbformat_minor": 2 342 | } 343 | -------------------------------------------------------------------------------- /ch13/13-user-comments.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": { 6 | "ein.tags": "worksheet-0", 7 | "slideshow": { 8 | "slide_type": "-" 9 | } 10 | }, 11 | "source": [ 12 | "# 文章评论\n", 13 | "\n", 14 | "本章将实现简单的用户评论功能。\n", 15 | "\n", 16 | "\n", 17 | "## 评论模型\n", 18 | "\n", 19 | "评论属于某篇博客文章,从`posts`表到`comments`表是一对多关系。使用这 个关系可以获取某篇特定博客文章的评论列表。\n", 20 | "\n", 21 | "`users`和`comments`表也是一对多关系,通过这个关系可以获取用户发表的所有评论,还能间接知道用户发表了多少篇评论。用户发表的评论数量可以显示在用户资料页中。\n", 22 | "\n", 23 | "下面时`Comment`模型定义:\n", 24 | "\n", 25 | "```python\n", 26 | "class Comment(db.Model):\n", 27 | " body = pw.TextField(null=True)\n", 28 | " body_html = pw.TextField(null=True)\n", 29 | " timestamp = pw.DateTimeField(index=True, default=datetime.utcnow)\n", 30 | " disabled = pw.BooleanField(null=True, default=False)\n", 31 | " author = pw.ForeignKeyField(User, related_name='comments', null=True)\n", 32 | " post = pw.ForeignKeyField(Post, related_name='comments', null=True)\n", 33 | "\n", 34 | " @require_instance\n", 35 | " def update_body_html(self):\n", 36 | " allowed_tags = ['a', 'abbr', 'acronym', 'b', 'code', 'em', 'i',\n", 37 | " 'strong']\n", 38 | " self.__class__.update(body_html=bleach.linkify(bleach.clean(\n", 39 | " markdown(self.body, output_format='html'),\n", 40 | " tags=allowed_tags, strip=True))).where(self._pk_expr()).execute()\n", 41 | "\n", 42 | " class Meta:\n", 43 | " db_table = 'comments'\n", 44 | "```\n", 45 | "\n", 46 | "`Comment`模型的属性几乎和`Post`模型一样,不过多了一个`disabled`字段。这是个布尔值字段,协管员通过这个字段查禁不当评论。和`Post`模型一样,评论也定义了一个`update_body_html`方法,用来更新`body_html`字段,其中 允许使用的HTML标签更严格,要删除与段落相关的标签,只留下格式化字符的标签。\n", 47 | "\n", 48 | "接着在`Post`中定义`comments_timeline`方法,来返回文章实例的评论列表:\n", 49 | "\n", 50 | "```python\n", 51 | "class Post(db.Model):\n", 52 | " # ...\n", 53 | " def comments_timeline(self, order='asc'):\n", 54 | " if order == 'asc':\n", 55 | " order = Comment.timestamp.asc()\n", 56 | " else:\n", 57 | " order = Comment.timestamp.desc()\n", 58 | "\n", 59 | " return (Comment.select(Comment, User)\n", 60 | " .join(User, on=(Comment.author == User.id))\n", 61 | " .where(Comment.post == self.id)\n", 62 | " .order_by(order))\n", 63 | "```\n", 64 | "\n", 65 | "\n", 66 | "## 提交和显示评论\n", 67 | "\n", 68 | "评论显示在单篇博客文章页面中,在这个页面中还要有一个提交评论的表单。\n", 69 | "\n", 70 | "评论输入表单示例:\n", 71 | "\n", 72 | "```python\n", 73 | "# app/main/forms.py\n", 74 | "class CommentForm(FlaskForm):\n", 75 | " body = StringField('Enter your comment', validators=[Required()])\n", 76 | " submit = SubmitField('Submit')\n", 77 | "```\n", 78 | "\n", 79 | "下面是为了支持评论而更新的`/post/<int:id>`路由:\n", 80 | "\n", 81 | "```python\n", 82 | "# app/main/views.py\n", 83 | "@main.route('/post/<int:id>', methods=['GET', 'POST'])\n", 84 | "def post(id):\n", 85 | " post_query = Post.select()\n", 86 | " post = futils.get_object_or_404(post_query, (Post.id == id))\n", 87 | " form = CommentForm()\n", 88 | " if form.validate_on_submit():\n", 89 | " comment = Comment(body=form.body.data,\n", 90 | " post=post,\n", 91 | " author=current_user._get_current_object())\n", 92 | " comment.save()\n", 93 | " comment.update_body_html()\n", 94 | " flash('Your comment has been published.')\n", 95 | " return redirect(url_for('.post', id=post.id, page=-1))\n", 96 | " page = request.args.get('page', 1, type=int)\n", 97 | " if page == -1:\n", 98 | " page = ((post.comments.count() - 1) //\n", 99 | " current_app.config['FLASKR_COMMENTS_PER_PAGE'] + 1)\n", 100 | " pagination = Pagination(post.comments_timeline(),\n", 101 | " current_app.config['FLASKR_COMMENTS_PER_PAGE'],\n", 102 | " page,\n", 103 | " check_bounds=False)\n", 104 | " comments = pagination.items\n", 105 | " return render_template('post.html', posts=[post], form=form,\n", 106 | " comments=comments, pagination=pagination)\n", 107 | "```\n", 108 | "\n", 109 | "评论按照时间戳顺序排列,新评论显示在列表的底部。提交评论后,请求结果是一个重定向,转回之前的URL,但是在`url_for()`函数的参数中把`page`设为`-1`,这个页数 用来请求评论的最后一页,所以刚提交的评论才会出现在页面中。程序从查询字符串中获取页数,发现值为`-1`时,会计算评论的总量和总页数,得出真正要显示的页数。\n", 110 | "\n", 111 | "评论的渲染过程在新模板`_comments.html`中进行,类似于`_posts.html`,但使用的 CSS 类不同。`_comments.html`模板要引入`post.html`中,放在文章正文下方,后面再显示分页导航。\n", 112 | "\n", 113 | "接着在首页和资料页中加上指向评论页面的链接:\n", 114 | "\n", 115 | "```django\n", 116 | "{# app/templates/_posts.html #}\n", 117 | "<a href=\"{{ url_for('.post', id=post.id) }}#comments\">\n", 118 | " <span class=\"label label-primary\">{{ post.comments.count() }} Comments</span>\n", 119 | "</a>\n", 120 | "```\n", 121 | "\n", 122 | "分页导航所用的宏也要做些改动。评论的分页导航链接也要加上`#comments`片段,因此在`post.html`模板中调用宏时,传入片段参数。\n", 123 | "\n", 124 | "**🔖 执行`git checkout 13a`签出程序的这个版本。**\n", 125 | "\n", 126 | "\n", 127 | "## 管理评论\n", 128 | "\n", 129 | "为了管理评论,要在导航条中添加一个链接,具有权限的用户才能看到。\n", 130 | "\n", 131 | "示例如下:\n", 132 | "\n", 133 | "```django\n", 134 | "{# app/templates/base.html #}\n", 135 | "{% if current_user.can(Permission.MODERATE_COMMENTS) %}\n", 136 | " <li><a href=\"{{ url_for('main.moderate') }}\">Moderate Comments</a></li>\n", 137 | "{% endif %}\n", 138 | "```\n", 139 | "\n", 140 | "管理页面在同一个列表中显示全部文章的评论,最近发布的评论会显示在前面。每篇评论的下方都会显示一个按钮,用来切换`disabled`属性的值。\n", 141 | "\n", 142 | "管理评论的路由定义如下:\n", 143 | "\n", 144 | "```python\n", 145 | "# app/main/views.py\n", 146 | "@main.route('/moderate')\n", 147 | "@login_required\n", 148 | "@permission_required(Permission.MODERATE_COMMENTS)\n", 149 | "def moderate():\n", 150 | " pagination = Pagination(\n", 151 | " Comment.timeline(),\n", 152 | " current_app.config['FLASKR_COMMENTS_PER_PAGE'],\n", 153 | " check_bounds=False)\n", 154 | " comments = pagination.items\n", 155 | " return render_template('moderate.html', comments=comments,\n", 156 | " pagination=pagination, page=pagination.page)\n", 157 | "```\n", 158 | "\n", 159 | "下面是评论管理页面的模板:\n", 160 | "\n", 161 | "```django\n", 162 | "{# app/templates/moderate.html #}\n", 163 | "{% extends \"base.html\" %}\n", 164 | "{% import \"_macros.html\" as macros %}\n", 165 | "\n", 166 | "{% block title %}Flaskr - Comment Moderation{% endblock %}\n", 167 | "\n", 168 | "{% block page_content %}\n", 169 | " <div class=\"page-header\">\n", 170 | " <h1>Comment Moderation</h1>\n", 171 | " </div>\n", 172 | " {% set moderate = True %}\n", 173 | " {% include '_comments.html' %}\n", 174 | " {% if pagination and pagination.pages > 1 %}\n", 175 | " <div class=\"pagination\">\n", 176 | " {{ macros.pagination_widget(pagination, '.moderate') }}\n", 177 | " </div>\n", 178 | " {% endif %}\n", 179 | "{% endblock %}\n", 180 | "```\n", 181 | "\n", 182 | "这个模板将渲染评论的工作交给`_comments.html`模板完成,但把控制权交给从属模板之前,会使用Jinja2提供的`set`指令定义一个模板变量`moderate`,并将其值设为`True`。这个变量用在`_comments.html`模板中,决定是否渲染评论管理功能。\n", 183 | "\n", 184 | "`_comments.html`模板中显示评论正文的部分要做两方面修改。对于普通用户(没设定`moderate`变量),不显示标记为有问题的评论。对于协管员(`moderate`设为`True`),不管评论是否被标记为有问题,都要显示,而且在正文下方还要显示一个用来切换状态的按钮。\n", 185 | "\n", 186 | "下面是渲染评论正文的模板:\n", 187 | "\n", 188 | "```django\n", 189 | "{# app/templates/_comments.html #}\n", 190 | "<div class=\"comment-body\">\n", 191 | " {% if comment.disabled %}\n", 192 | " <p><i>This comment has been disabled by a moderator.</i></p>\n", 193 | " {% endif %}\n", 194 | " {% if moderate or not comment.disabled %}\n", 195 | " {% if comment.body_html %}\n", 196 | " {{ comment.body_html | safe }}\n", 197 | " {% else %}\n", 198 | " {{ comment.body }}\n", 199 | " {% endif %}\n", 200 | " {% endif %}\n", 201 | "</div>\n", 202 | "{% if moderate %}\n", 203 | " <br>\n", 204 | " {% if comment.disabled %}\n", 205 | " <a class=\"btn btn-info btn-xs\"\n", 206 | " href=\"{{ url_for('.moderate_enable', id=comment.id, page=page) }}\">Enable</a>\n", 207 | " {% else %}\n", 208 | " <a class=\"btn btn-danger btn-xs\"\n", 209 | " href=\"{{ url_for('.moderate_disable', id=comment.id, page=page) }}\">Disable</a>\n", 210 | " {% endif %}\n", 211 | "{% endif %}\n", 212 | "```\n", 213 | "\n", 214 | "改动之后,用户将看到一个关于有问题评论的简短提示。协管员既能看到这个提示,也能看到评论的正文。在每篇评论的下方,协管员还能看到一个按钮,用来切换评论的状态。点击按钮后会触发两个新路由中的一个,但具体触发哪一个取决于 协管员要把评论设为什么状态。\n", 215 | "\n", 216 | "下面是评论管理路由的定义:\n", 217 | "\n", 218 | "```python\n", 219 | "# app/main/views.py\n", 220 | "@main.route('/moderate/enable/<int:id>')\n", 221 | "@login_required\n", 222 | "@permission_required(Permission.MODERATE_COMMENTS)\n", 223 | "def moderate_enable(id):\n", 224 | " comment = futils.get_object_or_404(Comment.select(),\n", 225 | " (Comment.id == id))\n", 226 | " comment.disabled = False\n", 227 | " comment.save()\n", 228 | " return redirect(url_for('.moderate',\n", 229 | " page=request.args.get('page', 1, type=int)))\n", 230 | "\n", 231 | "\n", 232 | "@main.route('/moderate/disable/<int:id>')\n", 233 | "@login_required\n", 234 | "@permission_required(Permission.MODERATE_COMMENTS)\n", 235 | "def moderate_disable(id):\n", 236 | " comment = futils.get_object_or_404(Comment.select(),\n", 237 | " (Comment.id == id))\n", 238 | " comment.disabled = True\n", 239 | " comment.save()\n", 240 | " return redirect(url_for('.moderate',\n", 241 | " page=request.args.get('page', 1, type=int)))\n", 242 | "```\n", 243 | "\n", 244 | "启用路由和禁用路由先加载评论对象,把`disabled`字段设为正确的值,再把评论对象写入数据库。最后,重定向到评论管理页面,如果查询字符串中指定了`page`参数,会将其传入重定向操作。`_comments.html`模板中的按钮指定了`page`参数,重定向后会返回之前的页面。\n", 245 | "\n", 246 | "**🔖 执行`git checkout 13b`签出程序的这个版本。**" 247 | ] 248 | } 249 | ], 250 | "metadata": { 251 | "kernelspec": { 252 | "display_name": "Python 3", 253 | "name": "python3" 254 | }, 255 | "name": "13-user-comments.ipynb" 256 | }, 257 | "nbformat": 4, 258 | "nbformat_minor": 2 259 | } 260 | -------------------------------------------------------------------------------- /ch15/15-testing.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": { 6 | "ein.tags": "worksheet-0", 7 | "slideshow": { 8 | "slide_type": "-" 9 | } 10 | }, 11 | "source": [ 12 | "# Flask应用测试\n", 13 | "\n", 14 | "编写单元测试主要有两个目的:\n", 15 | "\n", 16 | "1. 实现新功能时,确保新添加的代码按预期方式运行\n", 17 | "2. 每次修改程序后,保证现有代码的功能没有退化,改动没有影响原有代码的正常运行\n", 18 | "\n", 19 | "\n", 20 | "<a id=\"org49a6e41\"></a>\n", 21 | "\n", 22 | "## 获取代码覆盖报告\n", 23 | "\n", 24 | "代码覆盖工具用来统计单元测试检查了多少程序功能,并提供一个详细的报告,说明程序的哪些代码没有测试到。它能指引你为最需要测试的部分编写新测试。\n", 25 | "\n", 26 | "Python提供了一个优秀的代码覆盖工具,称为 [**coverage**](https://coverage.readthedocs.io),使用 pip 进行安装:\n", 27 | "\n", 28 | "```sh\n", 29 | "(flaskr_env3) $ pip install coverage\n", 30 | "```\n", 31 | "\n", 32 | "这个工具本身是一个命令行脚本,可在任何一个Python程序中检查代码覆盖。另外,其还提供了更方便的脚本访问功能,使用编程方式启动覆盖检查引擎。为了能更好地把覆盖检测集成到<code>manage.py</code>中,我们可以增强之前自定义的<code>test</code>命令,添加可选选项<code>--coverage</code>。\n", 33 | "\n", 34 | "```python\n", 35 | "# manage.py\n", 36 | "import os\n", 37 | "\n", 38 | "cov = None\n", 39 | "if os.environ.get('FLASK_COVERAGE'):\n", 40 | " import coverage\n", 41 | " cov = coverage.Coverage(branch=True, include='app/*')\n", 42 | " cov.start()\n", 43 | "\n", 44 | "# ...\n", 45 | "\n", 46 | "@app.cli.command()\n", 47 | "@click.option('--coverage', default=False, is_flag=True,\n", 48 | " help=('Run the coverage test.'))\n", 49 | "def test(coverage):\n", 50 | " \"\"\"Run the unit tests.\"\"\"\n", 51 | " if coverage and not os.environ.get('FLASK_COVERAGE'):\n", 52 | " import sys\n", 53 | " os.environ['FLASK_COVERAGE'] = '1'\n", 54 | " os.execvp(sys.executable, [sys.executable] + sys.argv)\n", 55 | " import unittest\n", 56 | " tests = unittest.TestLoader().discover('tests')\n", 57 | " unittest.TextTestRunner(verbosity=2).run(tests)\n", 58 | " if cov:\n", 59 | " cov.stop()\n", 60 | " cov.save()\n", 61 | " print('Coverage Summary:')\n", 62 | " cov.report()\n", 63 | " basedir = os.path.abspath(os.path.dirname(__file__))\n", 64 | " covdir = os.path.join(basedir, 'tmp/coverage')\n", 65 | " cov.html_report(directory=covdir)\n", 66 | " print('HTML version: file://%s/index.html' % covdir)\n", 67 | " cov.erase()\n", 68 | "\n", 69 | "\n", 70 | "# ...\n", 71 | "```\n", 72 | "\n", 73 | "<code>test()</code>函数收到<code>--coverage</code>选项的值后再启动覆盖检测已经晚了,那时全局作用域中的所有代码都已经执行了。为了检测的准确性,设定完环境变量<CODE>FLASK_COVERAGE</CODE>后,脚本会重启。再次运行时,脚本顶端的代码发现已经设定了环境变量,于是立即启动覆盖检测。\n", 74 | "\n", 75 | "函数<code>coverage.coverage()</code>用于启动覆盖检测引擎。<code>branch=True</code>选项开启分支覆盖分析,除了跟踪哪行代码已经执行外,还要检查每个条件语句的<code>True</code>分支 和<code>False</code>分支是否都执行了。<code>include</code>选项用来限制程序包中文件的分析范围,只对这些文件中的代码进行覆盖检测。如果不指定<code>include</code>选项,虚拟环境中安装的全部扩展和测试代码都会包含进覆盖报告中,给报告添加很多杂项。\n", 76 | "\n", 77 | "执行完所有测试后,<code>text()</code>函数会在终端输出报告,同时还会生成一个使用HTML编写的精美报告并写入硬盘。HTML格式的报告非常适合直观形象地展示覆盖信息,因为它按照源码的使用情况给代码行加上了不同的颜色。\n", 78 | "\n", 79 | "文本格式的报告示例如下:\n", 80 | "\n", 81 | " (flaskr_env3) $ flask test --help\n", 82 | " Usage: flask test [OPTIONS]\n", 83 | "\n", 84 | " Run the unit tests.\n", 85 | "\n", 86 | " Options:\n", 87 | " --coverage Run the coverage test.\n", 88 | " --help Show this message and exit.\n", 89 | "\n", 90 | " (flaskr_env3) $ flask test --coverage\n", 91 | " ...\n", 92 | " ----------------------------------------------------------------------\n", 93 | " Ran 21 tests in 8.683s\n", 94 | "\n", 95 | " OK\n", 96 | " Coverage Summary:\n", 97 | " Name Stmts Miss Branch BrPart Cover\n", 98 | " -----------------------------------------------------------------\n", 99 | " app/__init__.py 31 15 0 0 52%\n", 100 | " app/api_1_0/__init__.py 3 0 0 0 100%\n", 101 | " app/api_1_0/authentication.py 30 19 10 0 28%\n", 102 | " app/api_1_0/comments.py 40 29 12 0 21%\n", 103 | " app/api_1_0/decorators.py 11 3 2 0 62%\n", 104 | " app/api_1_0/errors.py 17 10 0 0 41%\n", 105 | " app/api_1_0/posts.py 38 25 8 0 28%\n", 106 | " app/api_1_0/users.py 30 22 12 0 19%\n", 107 | " app/auth/__init__.py 3 0 0 0 100%\n", 108 | " app/auth/forms.py 45 8 8 0 70%\n", 109 | " app/auth/views.py 82 64 32 0 16%\n", 110 | " app/decorators.py 20 4 4 1 71%\n", 111 | " app/exceptions.py 2 0 0 0 100%\n", 112 | " app/main/__init__.py 6 1 0 0 83%\n", 113 | " app/main/errors.py 20 15 6 0 19%\n", 114 | " app/main/forms.py 39 7 6 0 71%\n", 115 | " app/main/views.py 174 135 34 0 19%\n", 116 | " app/models.py 288 88 56 8 65%\n", 117 | " -----------------------------------------------------------------\n", 118 | " TOTAL 879 445 190 9 43%\n", 119 | " HTML version: file:///.../flaskr/tmp/coverage/index.html\n", 120 | "\n", 121 | "上述报告显示,整体覆盖率为 **44%** 。情况并不遭,但也不太好。现阶段,模型类是单元测试的关注焦点,它共包含 **288** 个语句,测试覆盖了其中 **65%** 的语句。\n", 122 | "\n", 123 | "很明显,<code>main</code>和<code>auth</code>蓝本中的<code>views.py</code>文件以及<code>api_1_0</code>蓝本中的路由的覆盖率都很低,因为我们没有为这些代码编写单元测试。\n", 124 | "\n", 125 | "通过这个报告,我们就能很容易确定向测试组件中添加哪些测试以提高覆盖率。但是并非程序的所有组成部分都像数据库模型那样易于测试。接下来我们将介绍更高级的测试策略,可用于测试视图函数、表单和模板。\n", 126 | "\n", 127 | "\n", 128 | "<a id=\"orgbd3b913\"></a>\n", 129 | "\n", 130 | "## Flask测试客户端\n", 131 | "\n", 132 | "程序的某些代码严重依赖运行中的程序所创建的环境。例如,不能直接调用视图函数中的代码进行测试,因为这个函数可能需要访问Flask上下文全局变量,如<code>request</code>或<code>session</code>;视图函数可能还等待接收 **POST** 请求中的表单数据,而且某些视图函数要求用户先登录。简而言之,视图函数只能在请求上下文和运行中的程序里运行。\n", 133 | "\n", 134 | "Flask内建了一个测试客户端用于解决(至少部分解决)这一问题。测试客户端能复现程序运行在Web服务器中的环境,让测试扮演成客户端从而发送请求。\n", 135 | "\n", 136 | "在测试客户端中运行的视图函数和正常情况下的没有太大区别,服务器收到请求,将其分配给适当的视图函数,视图函数生成响应,将其返回给测试客户端。执行视图函数后,生成的响应会传入测试,检查是否正确。\n", 137 | "\n", 138 | "\n", 139 | "<a id=\"org91e1149\"></a>\n", 140 | "\n", 141 | "### 测试Web程序\n", 142 | "\n", 143 | "下面是使用测试客户端编写的单元测试框架:\n", 144 | "\n", 145 | "```python\n", 146 | "# tests/test_client.py\n", 147 | "import unittest\n", 148 | "\n", 149 | "from flask import url_for\n", 150 | "from app import create_app, db\n", 151 | "from app.models import User, Role\n", 152 | "\n", 153 | "\n", 154 | "class FlaskClientTestCase(unittest.TestCase):\n", 155 | " def setUp(self):\n", 156 | " self.app = create_app('testing')\n", 157 | " self.app_context = self.app.app_context()\n", 158 | " self.app_context.push()\n", 159 | " db.database.create_tables(db.models, safe=True)\n", 160 | " Role.insert_roles()\n", 161 | " self.client = self.app.test_client(use_cookies=True)\n", 162 | "\n", 163 | " def tearDown(self):\n", 164 | " db.database.drop_tables(db.models, safe=True)\n", 165 | " self.app_context.pop()\n", 166 | "\n", 167 | " def test_home_page(self):\n", 168 | " response = self.client.get(url_for('main.index'))\n", 169 | " self.assertTrue(b'Stranger' in response.data)\n", 170 | "```\n", 171 | "\n", 172 | "实例变量<code>self.client</code>是Flask测试客户端对象。在这个对象上可调用方法向程序发起请求。如果创建测试客户端时启用了<code>use_cookies</code>选项,这个测试客户端就能像浏览器一样接收和发送cookie,因此能使用依赖cookie的功能记住请求之间的上下文。这个选项还可用来启用用户会话,让用户登录和退出。\n", 173 | "\n", 174 | "<code>test_home_page()</code>测试作为一个简单的例子演示了测试客户端的作用。\n", 175 | "\n", 176 | "客户端向首页发起了一个请求。在测试客户端上调用<code>get()</code>方法得到的结果是一个<code>Response</code>对象,内容是调用视图函数得到的响应。为了检查测试是否成功,要在响应主体中搜索是否包含 **“Stranger”** 这个词。响应主体可使用<code>response.get_data()</code>获取,而 “Stranger” 这个词包含在向匿名用户显示的欢迎消息“Hello, Stranger!”中。\n", 177 | "\n", 178 | "⚠ 默认情况下<code>get_data()</code>得到的响应主体是一个字节数组,传入参数<code>as_text=True</code>后得到的是一个更易于处理的Unicode字符串。\n", 179 | "\n", 180 | "测试客户端还能使用<code>post()</code>方法发送包含表单数据的POST请求。Flask-WTF生成的表单中包含一个隐藏字段,其内容是CSRF令牌,需要和表单中的数据一起提交。为了复现这个功能,测试必须请求包含表单的页面,然后解析响应返回的 HTML 代码并提取令牌,这样才能把令牌和表单中的数据一起发送。为了避免在测试中处理CSRF令牌这一烦琐操作,最好在测试配置中禁用 CSRF 保护功能。\n", 181 | "\n", 182 | "```python\n", 183 | "# config.py\n", 184 | "\n", 185 | "class TestingConfig(Config):\n", 186 | " # ...\n", 187 | " WTF_CSRF_ENABLED = False\n", 188 | "```\n", 189 | "\n", 190 | "下面是一个更为高级的单元测试,模拟了新用户注册账户、登录、使用确认令牌确认账户以及退出的过程。\n", 191 | "\n", 192 | "```python\n", 193 | "# tests/test_client.py\n", 194 | "\n", 195 | "class FlaskClientTestCase(unittest.TestCase):\n", 196 | " # ...\n", 197 | " def test_register_and_login(self):\n", 198 | " # register a new account\n", 199 | " response = self.client.post(\n", 200 | " url_for('auth.register'),\n", 201 | " data={'email': 'john@example.com',\n", 202 | " 'username': 'john',\n", 203 | " 'password': 'cat',\n", 204 | " 'password2': 'cat'})\n", 205 | " self.assertTrue(response.status_code == 302)\n", 206 | "\n", 207 | " # login with the new account\n", 208 | " response = self.client.post(\n", 209 | " url_for('auth.login'),\n", 210 | " data={'email': 'john@example.com',\n", 211 | " 'password': 'cat'},\n", 212 | " follow_redirects=True)\n", 213 | " self.assertTrue(re.search(b'Hello,\\s+john\\s+!', response.data))\n", 214 | "\n", 215 | " # send a confirmation token\n", 216 | " user = User.select().where(User.email == 'john@example.com').first()\n", 217 | " token = user.generate_confirmation_token()\n", 218 | " response = self.client.get(url_for('auth.confirm', token=token),\n", 219 | " follow_redirects=True)\n", 220 | " self.assertTrue(b'You have confirmed your account' in response.data)\n", 221 | "\n", 222 | " # log out\n", 223 | " response = self.client.get(url_for('auth.logout'),\n", 224 | " follow_redirects=True)\n", 225 | " self.assertTrue(b'You have been logged out' in response.data)\n", 226 | "```\n", 227 | "\n", 228 | "**🔖 执行<code>git checkout 15b</code>签出程序的这个版本。**\n", 229 | "\n", 230 | "\n", 231 | "<a id=\"orgf3be2f6\"></a>\n", 232 | "\n", 233 | "### 测试Web服务\n", 234 | "\n", 235 | "下面的实例展示了如何使用Flask测试客户端测试REST Web服务:\n", 236 | "\n", 237 | "```python\n", 238 | "# tests/test_api.py\n", 239 | "class APITestCase(unittest.TestCase):\n", 240 | " # ...\n", 241 | " def get_api_headers(self, username, password):\n", 242 | " return {\n", 243 | " 'Authorization': 'Basic ' + b64encode(\n", 244 | " (username + ':' + password).encode('utf-8')).decode('utf-8'),\n", 245 | " 'Accept': 'application/json',\n", 246 | " 'Content-Type': 'application/json'\n", 247 | " }\n", 248 | "\n", 249 | " def test_posts(self):\n", 250 | " # add a user\n", 251 | " r = Role.select().where(Role.name == 'User').first()\n", 252 | " self.assertIsNotNone(r)\n", 253 | " u = User(email='rose@example.com',\n", 254 | " username='rose',\n", 255 | " password='cat', confirmed=True,\n", 256 | " role=r)\n", 257 | " u.save()\n", 258 | "\n", 259 | " # write an empty post\n", 260 | " response = self.client.post(\n", 261 | " url_for('api.new_post'),\n", 262 | " headers=self.get_api_headers('rose@example.com', 'cat'),\n", 263 | " data=json.dumps({'body': ''}))\n", 264 | " self.assertTrue(response.status_code == 400)\n", 265 | "\n", 266 | " # write a post\n", 267 | " response = self.client.post(\n", 268 | " url_for('api.new_post'),\n", 269 | " headers=self.get_api_headers('rose@example.com', 'cat'),\n", 270 | " data=json.dumps({'body': 'body of the *blog* post'}))\n", 271 | " self.assertTrue(response.status_code == 201)\n", 272 | " url = response.headers.get('Location')\n", 273 | " self.assertIsNotNone(url)\n", 274 | "\n", 275 | " # get the new post\n", 276 | " response = self.client.get(\n", 277 | " url,\n", 278 | " headers=self.get_api_headers('rose@example.com', 'cat'))\n", 279 | " self.assertTrue(response.status_code == 200)\n", 280 | " json_response = json.loads(response.data.decode('utf-8'))\n", 281 | " self.assertTrue(json_response['url'] == url)\n", 282 | " self.assertTrue(json_response['body'] == 'body of the *blog* post')\n", 283 | " self.assertTrue(json_response['body_html'] ==\n", 284 | " '<p>body of the <em>blog</em> post</p>')\n", 285 | " json_post = json_response\n", 286 | "\n", 287 | " # get the post from the user\n", 288 | " response = self.client.get(\n", 289 | " url_for('api.get_user_posts', id=u.id),\n", 290 | " headers=self.get_api_headers('rose@example.com', 'cat'))\n", 291 | " self.assertTrue(response.status_code == 200)\n", 292 | " json_response = json.loads(response.data.decode('utf-8'))\n", 293 | " self.assertIsNotNone(json_response.get('posts'))\n", 294 | " self.assertTrue(json_response.get('count', 0) == 1)\n", 295 | " self.assertTrue(json_response['posts'][0] == json_post)\n", 296 | "\n", 297 | " # get the post from the user as a follower\n", 298 | " response = self.client.get(\n", 299 | " url_for('api.get_user_followed_posts', id=u.id),\n", 300 | " headers=self.get_api_headers('rose@example.com', 'cat'))\n", 301 | " self.assertTrue(response.status_code == 200)\n", 302 | " json_response = json.loads(response.data.decode('utf-8'))\n", 303 | " self.assertIsNotNone(json_response.get('posts'))\n", 304 | " self.assertTrue(json_response.get('count', 0) == 1)\n", 305 | " self.assertTrue(json_response['posts'][0] == json_post)\n", 306 | "\n", 307 | " # edit post\n", 308 | " response = self.client.put(\n", 309 | " url,\n", 310 | " headers=self.get_api_headers('rose@example.com', 'cat'),\n", 311 | " data=json.dumps({'body': 'updated body'}))\n", 312 | " self.assertTrue(response.status_code == 200)\n", 313 | " json_response = json.loads(response.data.decode('utf-8'))\n", 314 | " self.assertTrue(json_response['url'] == url)\n", 315 | " self.assertTrue(json_response['body'] == 'updated body')\n", 316 | " self.assertTrue(json_response['body_html'] == '<p>updated body</p>')\n", 317 | "```\n", 318 | "\n", 319 | "测试API时使用的<code>setUp()</code>和<code>tearDown()</code>方法和测试普通程序所用的一样, 不过API不使用cookie,所以无需配置相应支持。<code>get_api_headers()</code>是一个辅助方法,返回所有请求都要发送的通用首部,其中包含认证密令和MIME类型相关的首部。大多数测试都要发送这些首部。\n", 320 | "\n", 321 | "<code>test_posts()</code>测试把一个用户插入数据库,然后使用基于REST的API创建一篇博客文章, 然后再读取这篇文章。所有请求主体中发送的数据都要使用<code>json.dumps()</code>方法进行编码,因为Flask测试客户端不会自动编码JSON格式数据。类似地,返回的响应主体也是JSON格式,处理之前必须使用<code>json.loads()</code>方法解码。\n", 322 | "\n", 323 | "**🔖 执行<code>git checkout 15c</code>签出程序的这个版本。**\n", 324 | "\n", 325 | "\n", 326 | "<a id=\"org45a3358\"></a>\n", 327 | "\n", 328 | "## 使用Selenium进行端到端测试\n", 329 | "\n", 330 | "Flask测试客户端不能完全模拟运行中的程序所在的环境,例如无法像在真正的Web浏览器客户端中那样运行JavaScript代码。如果测试需要完整的环境,只能使用真正的Web浏览器连接Web服务器来运行程序。大多数浏览器都支持自动化操作。\n", 331 | "\n", 332 | "[Selenium](http://www.seleniumhq.org/)是一个Web浏览器自动化工具,支持3种主要操作系统中的大多数主流Web浏览器。\n", 333 | "\n", 334 | "使用pip来安装:\n", 335 | "\n", 336 | "```\n", 337 | "(flaskr_env3) $ pip install selenium\n", 338 | "```\n", 339 | "\n", 340 | "**安装WebDriver**\n", 341 | "\n", 342 | "下载支持Chrome浏览器的[ChromeDriver](https://sites.google.com/a/chromium.org/chromedriver/home)<sup><a id=\"fnr.1\" class=\"footref\" href=\"#fn.1\">1</a></sup>,注意查看其版本号,确保所下载的WebDriver支持你当前使用的浏览器版本。\n", 343 | "\n", 344 | "将下载后的ChromeDriver安装到系统环境变量<CODE>PATH</CODE>设置的路径下面。<sup><a id=\"fnr.2\" class=\"footref\" href=\"#fn.2\">2</a></sup>\n", 345 | "\n", 346 | "使用Selenium进行的测试要求程序在Web服务器中运行,监听真实的HTTP请求。程序运行在后台线程里的开发服务器中,而测试运行在主线程中。在测试的控制下,Selenium启动Web浏览器并连接程序以执行所需操作。\n", 347 | "\n", 348 | "使用这种方法要解决一个问题,即当所有测试都完成后,要停止Flask服务器,Werkzeug Web服务器本身就有停止选项,但由于服务器运行在单独的线程中,关闭服务器的唯一方法是发送一个普通的HTTP请求。\n", 349 | "\n", 350 | "下面代码实现了关闭服务器的路由:\n", 351 | "\n", 352 | "```python\n", 353 | "# app/main/views.py\n", 354 | "@main.route('/shutdown')\n", 355 | "def server_shutdown():\n", 356 | " if not current_app.testing:\n", 357 | " abort(404)\n", 358 | " shutdown = request.environ.get('werkzeug.server.shutdown')\n", 359 | " if not shutdown:\n", 360 | " abort(500)\n", 361 | " shutdown()\n", 362 | " return 'Shutting down...'\n", 363 | "```\n", 364 | "\n", 365 | "只有当程序运行在测试环境中时,这个关闭服务器的路由才可用,在其他配置中调用时将不起作用。在实际过程中,关闭服务器时要调用Werkzeug在环境中提供的关闭函数。调用这个函数且请求处理完成后,开发服务器就知道自己需要优雅地退出了。\n", 366 | "\n", 367 | "下面是使用Selenium运行测试时测试用例所用的代码结构:\n", 368 | "\n", 369 | "```python\n", 370 | "# tests/test_selenium.py\n", 371 | "from selenium import webdriver\n", 372 | "\n", 373 | "\n", 374 | "class SeleniumTestCase(unittest.TestCase):\n", 375 | " client = None\n", 376 | "\n", 377 | " @classmethod\n", 378 | " def setUpClass(cls):\n", 379 | " # start Chrome\n", 380 | " try:\n", 381 | " cls.client = webdriver.Chrome()\n", 382 | " except:\n", 383 | " pass\n", 384 | "\n", 385 | " # skip these tests if the browser could not be started\n", 386 | " if cls.client:\n", 387 | " # create the application\n", 388 | " cls.app = create_app('testing')\n", 389 | " cls.app_context = cls.app.app_context()\n", 390 | " cls.app_context.push()\n", 391 | "\n", 392 | " # suppress logging to keep unittest output clean\n", 393 | " import logging\n", 394 | " logger = logging.getLogger('werkzeug')\n", 395 | " logger.setLevel(\"ERROR\")\n", 396 | "\n", 397 | " # create the database and populate with some fake data\n", 398 | " db.database.create_tables(db.models, safe=True)\n", 399 | " Role.insert_roles()\n", 400 | " User.generate_fake(10)\n", 401 | " Post.generate_fake(10)\n", 402 | "\n", 403 | " # add an administrator user\n", 404 | " admin_role = Role.select().where(Role.permissions == 0xff).first()\n", 405 | " admin = User(email='john@example.com',\n", 406 | " username='john', password='cat',\n", 407 | " role=admin_role, confirmed=True)\n", 408 | " admin.save()\n", 409 | "\n", 410 | " # start the Flask server in a thread\n", 411 | " threading.Thread(target=cls.app.run).start()\n", 412 | "\n", 413 | " # give the server a second to ensure it is up\n", 414 | " time.sleep(1)\n", 415 | "\n", 416 | " @classmethod\n", 417 | " def tearDownClass(cls):\n", 418 | " if cls.client:\n", 419 | " # stop the flask server and the browser\n", 420 | " cls.client.get('http://localhost:5000/shutdown')\n", 421 | " cls.client.close()\n", 422 | "\n", 423 | " # destroy database\n", 424 | " db.database.drop_tables(db.models, safe=True)\n", 425 | "\n", 426 | " # remove application context\n", 427 | " cls.app_context.pop()\n", 428 | "\n", 429 | " def setUp(self):\n", 430 | " if not self.client:\n", 431 | " self.skipTest('Web browser not available')\n", 432 | "\n", 433 | " def tearDown(self):\n", 434 | " pass\n", 435 | "```\n", 436 | "\n", 437 | "<code>setUpClass()</code>和<code>tearDownClass()</code>类方法分别在这个类中的全部测试运行前、后执行。\n", 438 | "\n", 439 | "<code>setUpClass()</code>方法使用Selenium提供的webdriver API启动一个 Chrome 实例,并创建一个程序和数据库,其中写入了一些供测试使用的初始数据。然后调用标准的<code>app.run()</code>方法在一个线程中启动程序。完成所有测试后,程序会收到一个发往<code>/shutdown</code>的请求,进而停止后台线程。随后,关闭浏览器,删除测试数据库。\n", 440 | "\n", 441 | "<code>setUp()</code>方法在每个测试运行之前执行,如果Selenium无法利用<code>startUpClass()</code>方法启动Web浏览器就跳过测试。\n", 442 | "\n", 443 | "下面是一个使用Selenium进行测试的例子:\n", 444 | "\n", 445 | "```python\n", 446 | "# tests/test_selenium.py\n", 447 | "class SeleniumTestCase(unittest.TestCase):\n", 448 | " # ...\n", 449 | "\n", 450 | " def test_admin_home_page(self):\n", 451 | " admin = User.select().where(User.email == 'john@example.com').first()\n", 452 | " self.assertTrue(isinstance(admin, User))\n", 453 | "\n", 454 | " # navigate to home page\n", 455 | " self.client.get('http://localhost:5000/')\n", 456 | " self.assertTrue(re.search('Hello,\\s+Stranger\\s+!',\n", 457 | " self.client.page_source))\n", 458 | "\n", 459 | " # navigate to login page\n", 460 | " self.client.find_element_by_link_text('Log In').click()\n", 461 | " self.assertTrue('<h1>Login</h1>' in self.client.page_source)\n", 462 | "\n", 463 | " # login\n", 464 | " self.client.find_element_by_name('email').\\\n", 465 | " send_keys('john@example.com')\n", 466 | " self.client.find_element_by_name('password').send_keys('cat')\n", 467 | " self.client.find_element_by_name('submit').click()\n", 468 | " self.assertTrue(re.search('Hello,\\s+john\\s+!', self.client.page_source))\n", 469 | "\n", 470 | " # navigate to the user's profile page\n", 471 | " self.client.find_element_by_link_text('Profile').click()\n", 472 | " self.assertTrue('<h1>john</h1>' in self.client.page_source)\n", 473 | "```\n", 474 | "\n", 475 | "这个测试使用<code>setUpClass()</code>方法中创建的管理员账户登录程序,然后打开资料页。使用Selenium进行测试时,测试向Web浏览器发出指令且从不直接和程序交互。发给浏览器的指令和真实用户使用鼠标或键盘执行的操作几乎一样。\n", 476 | "\n", 477 | "这个测试首先调用<code>get()</code>方法访问程序的首页。在浏览器中,这个操作就是在地址栏 中输入URL。为了验证这一步操作的结果,测试代码检查页面源码中是否包含“Hello, Stranger!” 这个欢迎消息。\n", 478 | "\n", 479 | "为了访问登录页面,测试使用<code>find_element_by_link_text()</code>方法查找“Log In”链接,然后在这个链接上调用<code>click()</code>方法,从而在浏览器中触发一次真正的点击。Selenium提供了很多<code>find_element_by...()</code>简便方法,可使用不同的方式搜索元素。\n", 480 | "\n", 481 | "为了登录程序,测试使用<code>find_element_by_name()</code>方法通过名字找到表单中的电子邮件 和密码字段,然后再使用<code>send_keys()</code>方法在各字段中填入值。表单的提交通过在提交按钮上调用<code>click()</code>方法完成。此外,还要检查针对用户定制的欢迎消息,以确保登录成功且浏览器显示的是首页。\n", 482 | "\n", 483 | "测试的最后一部分是找到导航条中的“Profile”链接,然后点击。为证实资料页已经加载,测试要在页面源码中搜索内容为用户名的标题。\n", 484 | "\n", 485 | "**🔖 执行<code>git checkout 15d</code>签出程序的这个版本。**\n", 486 | "\n", 487 | "最后再来进行一次代码覆盖测试:\n", 488 | "\n", 489 | " (flaskr_env3) $ flask test --coverage\n", 490 | " ...\n", 491 | " ----------------------------------------------------------------------\n", 492 | " Ran 33 tests in 35.238s\n", 493 | "\n", 494 | " OK\n", 495 | " Coverage Summary:\n", 496 | " Name Stmts Miss Branch BrPart Cover\n", 497 | " -----------------------------------------------------------------\n", 498 | " app/__init__.py 31 15 0 0 52%\n", 499 | " app/api_1_0/__init__.py 3 0 0 0 100%\n", 500 | " app/api_1_0/authentication.py 30 2 10 2 90%\n", 501 | " app/api_1_0/comments.py 40 4 12 4 85%\n", 502 | " app/api_1_0/decorators.py 11 1 2 1 85%\n", 503 | " app/api_1_0/errors.py 17 0 0 0 100%\n", 504 | " app/api_1_0/posts.py 38 3 8 3 87%\n", 505 | " app/api_1_0/users.py 30 4 12 4 81%\n", 506 | " app/auth/__init__.py 3 0 0 0 100%\n", 507 | " app/auth/forms.py 45 6 8 2 77%\n", 508 | " app/auth/views.py 90 48 36 4 40%\n", 509 | " app/decorators.py 20 4 4 1 71%\n", 510 | " app/exceptions.py 2 0 0 0 100%\n", 511 | " app/main/__init__.py 6 0 0 0 100%\n", 512 | " app/main/errors.py 20 10 6 0 46%\n", 513 | " app/main/forms.py 39 7 6 0 71%\n", 514 | " app/main/views.py 182 122 38 4 30%\n", 515 | " app/models.py 288 18 56 11 90%\n", 516 | " -----------------------------------------------------------------\n", 517 | " TOTAL 895 244 198 36 68%\n", 518 | " HTML version: file:///.../flaskr/tmp/coverage/index.html\n", 519 | "\n", 520 | "## Footnotes\n", 521 | "\n", 522 | "<sup><a id=\"fn.1\" class=\"footnum\" href=\"#fnr.1\">1</a></sup> 使用Firefox浏览器的话,可下载[GeckoDriver](https://github.com/mozilla/geckodriver)。\n", 523 | "\n", 524 | "<sup><a id=\"fn.2\" class=\"footnum\" href=\"#fnr.2\">2</a></sup> 更详细的信息[这里](https://github.com/SeleniumHQ/selenium/wiki/ChromeDriver)。" 525 | ] 526 | } 527 | ], 528 | "metadata": { 529 | "kernelspec": { 530 | "display_name": "Python 3", 531 | "name": "python3" 532 | }, 533 | "name": "15-testing.ipynb" 534 | }, 535 | "nbformat": 4, 536 | "nbformat_minor": 2 537 | } 538 | -------------------------------------------------------------------------------- /images/flask-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edu2act/course-python-web-high/16bd45e717d5fbb9e39941d6868d0ce2d1eb704f/images/flask-logo.png -------------------------------------------------------------------------------- /images/http_client-server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edu2act/course-python-web-high/16bd45e717d5fbb9e39941d6868d0ce2d1eb704f/images/http_client-server.png -------------------------------------------------------------------------------- /images/python-web-simple-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edu2act/course-python-web-high/16bd45e717d5fbb9e39941d6868d0ce2d1eb704f/images/python-web-simple-architecture.png -------------------------------------------------------------------------------- /materials/syllabus.md: -------------------------------------------------------------------------------- 1 | # 《Python Web 开发》教学大纲 2 | 3 | - [大纲说明](#org36ef14e) 4 | - [教学设计](#org67823e4) 5 | - [教学内容细化](#org32a440a) 6 | 7 | <a id="org36ef14e"></a> 8 | 9 | ## 大纲说明 10 | 11 | 《Python Web 开发》课程是河北师范大学软件学院软件工程专业机器学习方向的专业必修课,本教学大纲适用于相应专业方向的本科生教学。 12 | 13 | 学习本课程之前,学生应具备一定的Python编程经验,能理解Python中的一些概念,例如包、模块、函数、装饰器和面向对象编程,熟悉异常处理,知道如何从栈跟踪中分析问题。本课程中很多示例代码需要在命令行中进行操作,所以学生应该能够熟练使用自己操作系统中的命令行。本课程中开发的示例程序不可避免地需要使用HTML、CSS和JavaScript,需要学生对这些语言有一定程度的了解。另外,学生需要熟悉源码版本控制工具Git。 14 | 15 | 通过本课程的学习,要求学生达到下列基本目标: 16 | 17 | - 了解Python Web开发的基本内容 18 | - 学会如何使用Flask框架及其相关的一些扩展开发Web程序 19 | 20 | 21 | <a id="org67823e4"></a> 22 | 23 | ## 教学设计 24 | 25 | 本课程在教学上采用理论与实践相结合的授课方式。 理论部分通过多媒体课件来讲解基本的概念、思想、方法和原理,实践部分主要以课上作业任务的 方式来使学生巩固理论知识并提高其实际编程能力。 26 | 27 | - 教学内容及学时分配 28 | 29 | | 序号 | 主要内容 | 学时分配 | 理论课时 | 实验课时 | 30 | |------|----------------|----------|----------|----------| 31 | | 1 | Python Web开发初识 | 3 | 2 | 1 | 32 | | 2 | Flask框架简介 | 5 | 4 | 1 | 33 | | 3 | 模板 | 7 | 6 | 1 | 34 | | 4 | Web表单处理 | 8 | 6 | 2 | 35 | | 5 | Flask命令行接口 | 8 | 6 | 2 | 36 | | 6 | 使用ORM操作数据库 | 8 | 6 | 2 | 37 | | 7 | Flask大型应用程序结构 | 7 | 6 | 1 | 38 | | 8 | 社交博客实例 | 34 | 24 | 10 | 39 | | 9 | Web服务和API | 8 | 6 | 2 | 40 | | 10 | Flask信号处理 | 6 | 4 | 2 | 41 | | 11 | Werkzeug的使用 | 5 | 4 | 1 | 42 | | 12 | 部署Flask应用 | 8 | 6 | 2 | 43 | | 13 | Web开发实践 | 5 | 4 | 1 | 44 | | 合计 | | 112 | 84 | 28 | 45 | 46 | 47 | <a id="org32a440a"></a> 48 | 49 | ## 教学内容细化 50 | 51 | - Python Web开发初识 52 | 53 | - Flask框架简介 54 | 55 | - 模板 56 | 57 | - Web表单处理 58 | 59 | - Flask命令行接口 60 | 61 | - 使用ORM操作数据库 62 | 63 | - Flask大型应用程序结构 64 | 65 | - 社交博客实例 66 | 67 | - Web服务和API 68 | 69 | - Flask信号处理 70 | 71 | - Werkzeug的使用 72 | 73 | - 部署Flask应用 74 | 75 | - Web开发实践 76 | --------------------------------------------------------------------------------