---
tags: Python
---
# Hello Card Generator 從零開始
## 簡介
### preface
KKBOX 應該是個很潮的公司,用新技術新想法在經營我們的音樂服務。但是我們新人報到後,寄出的 hello mail 裡頭的 hello card 卻是個用 PowerPoint 檔案,真的很老套,新人不該忍受這一切。
### story
* 身為新人,我想隨時可以透過手機或網頁連到 newcomer.kkinternal.com 就可以透過簡單地設定與上傳照片,就能讓我把 hello mail 送出,同時會在 Slack 的 #newcomer 頻道中把自我介紹貼出來,讓大家認識我。
* 身為 HR,我能夠不再需要準備 hello card 的 PowerPoint sample 檔案給報到的新人,這樣我可把時間節省下來做更重要的事情。
* 身為已經在公司的成員,我不再需要收到 hello mail 或安裝什麼軟體才能夠認識到新人叫什麼名字,長什麼樣子。而且我能夠在 Slack 的 #newcomer 頻道認識到新人,可以即刻在那邊跟新人互動。
## 使用 [Flask](http://flask.pocoo.org/) 建立網頁框架
閱讀 [Flask 官方文件](http://flask.pocoo.org/)之後,對於 Flask 會有基本的了解,即可開始建立一個網頁框架。
1. Clone [tutorial-hello-card-generator](https://gitlab.kkinternal.com/PET/tutorial-hello-card-generator/tree/basic) 的專案檔,此專案為 [flask-skeleton](https://github.com/realpython/flask-skeleton) 進行刪減後的版本。
`$ git clone git@github.com:CatMao1230/hello_card.git`
1. 進入專案資料夾。
`$ cd tutorial-hello-card-generator`
1. 設定環境變數。
`$ export APP_SETTINGS="app.server.config.DevelopmentConfig"`
1. 安裝 `requirements.txt` 內的所有套件,很多專案都會將所需的套件列在 `requirments.txt` 裡面,要一個一個安裝太過於麻煩,可以下指令安裝在 `requirments.txt` 裡的所有套件,文件內包含著套件名稱與版本。
`$ pip install -r requirements.txt`
1. 啟動伺服器。
`$ python manage.py runserver`
1. 打開網頁 http://localhost:5000/ 查看成果,可以發現網頁標題為 Hello-Card-Generator ,網頁內容一片空白,代表建立網頁框架成功。
1. `Ctrl + C` 終止程式。
## 使用虛擬環境 [Virtualenv](https://virtualenv.pypa.io/en/stable/)
可以從 `requirements.txt` 裡面看到專案需要安裝四個套件,可以直接安裝套件在電腦內,但如果一台電腦有許多專案,而每個專案需要的套件版本都不盡相同,會造成版本控管上的麻煩,虛擬環境可以替我們解決此問題,讓每個專案都有獨立的環境。
### 安裝 Virtualenv
1. 在 termianal 裡下指令來安裝。
`$ pip install virtualenv`
> pip 是 Python 的套件管理工具,它集合下載、安裝、升級、管理、移除套件等功能,藉由統一的管理,可以使我們事半功倍,更重要的是,也避免了手動執行上述任務會發生的種種錯誤。 pip 的安裝非常簡單,請前往 [pip 的官方文件網站](http://pip.readthedocs.org/en/latest/installing.html) 。
### 使用 Virtualenv
1. 在我們想要新增虛擬環境的專案資料夾下新增一個 `venv` , `venv` 是我們取的一個名字,可隨自己喜好。
`$ virtualenv venv`
1. 啟動虛擬環境,會發現 terminal 上多了一個虛擬使用者 `(venv)` ,表示啟動成功。
`$ source venv/bin/activate`
1. 啟動成功後就可以像平常一樣下指令安裝。
1. 可用指令列出目前有安裝的套件及版本,查看有啟動虛擬環境與尚未啟動的差別。
`$ pip freeze`
1. 若有第三方程式庫,都可以在 `venv/lib/Python2.7/site-packages` 裡看到已下載好的。
1. 退出虛擬環境。
`$ deactivate`
## 使用 [uwsgi](https://uwsgi-docs.readthedocs.io/en/latest/index.html) 啟動伺服器
一台電腦有可能同時建置許多網頁伺服器,若路徑都相同會造成相衝,也為了能夠讓網址有意義,因此需要設定網址。現在的網頁網址為 http://localhost:5000/ ,我們想要改為 http://localhost:12345/hello-card-generator/ ,能使用 uwsgi 來快速部署 Python 應用。
1. `$ pip install uWSGI`
安裝 uWSGI ,建議將 `uWSGI==2.0.12` 的名稱與版本加至 `requirements.txt` 內再行安裝,如此一來別人在開啟專案時,也知道專案有使用 uwsgi ,後續安裝套件時也都是如此。
1. `$ vim dev.ini` 創建一個 ini 檔案,作為設定檔,輸入下列參數設定。
```
[uwsgi]
http = :12345
mount = /hello-card-generator=run.py
manage-script-name = true
die-on-term = true
py-autoreload = 2
```
1. `$ vim run.py` 創建一個執行檔,輸入下方程式碼。
```
import logging
from app.server import app as application
logging.basicConfig(filename='/tmp/hello-card-generator.log', filemode='w', level=logging.DEBUG,
format='%(asctime)s [%(threadName)s] %(levelname)-6s %(name)s - %(message)s')
```
1. `$ uwsgi --ini dev.ini` 開啟伺服器,可以打開 http://localhost:12345/hello-card-generator/ 查看成果。
1. `Ctrl + C` 中止程式。
### 路徑設定問題
可以發現雖然是透過 http://localhost:12345/hello-card-generator/ 查看網頁,但輸入 http://localhost:12345/ 竟然也能夠成功查看網頁,這是因為在預設下,如果載入網址沒有與 mountpoint 相符,將會被安裝為預設目錄(直接導入根目錄),為了解決此問題,我們需要在 `dev.ini` 裡加上一個設定 [`no-default-app = true`](http://uwsgi-docs-additions.readthedocs.io/en/latest/Options.html#no-default-app) ,再重新執行一次 uwsgi ,就發現問題解決了,有問題的頁面會出現 “ Internal Server Error ” 。
```
[uwsgi]
http = :12345
mount = /hello-card-generator=run.py
manage-script-name = true
die-on-term = true
py-autoreload = 2
no-default-app = true
```
閱讀更多關於 [Multiple flask apps in different mountpoints](http://uwsgi-docs.readthedocs.io/en/latest/Snippets.html#multiple-flask-apps-in-different-mountpoints) 。
## 編輯網頁
### 更改內容
可以在 `app/client/templates` 裡看到專案所有的 html 檔, `templates/main/index.html` 是首頁,開啟後進行編輯。
- 加上一行標題,可以看到網頁左上角出現了文字 “ Hello Card Generator ” 。
```
<div>
Hello Card Generator
</div>
```
### 更改標題
現在我們要將網頁標題改成 Hello Card ,但卻在 `main/index.html` 內沒有看到 title 的設定,這是因為 [Flask templates](http://flask.pocoo.org/docs/0.12/tutorial/templates/) 讓網頁可以繼承,用一個基本的網頁再去包一個內容網頁,如此一來當基本網頁修改時,其他頁面不需要額外再做更動。
1. 修改 `templates/_base.html` 。
```
<title>Hello Card{% block title %}{% endblock %}</title>
```
1. 其他的基本網頁設定也都可以在此修改,如引入 css 、 js 。
## 使用 [WTForms](https://wtforms.readthedocs.io/en/latest/) 創建輸入欄位
完成了一切的準備後,就可以開始使用 Flask-WTF 創建第一個輸入欄位。
### 安裝 Flask-WTF
`$ pip install Flask-WTF`
### 創建輸入欄位
[StringField](http://wtforms.readthedocs.io/en/latest/fields.html#wtforms.fields.StringField) 可以讓 HTML 產生 `<input type="text">` 的標籤,因此使用 StringField 來創建第一個輸入欄位,讓使用者輸入姓名。
1. 創建 `server/main/forms.py` ,生成一個自訂的 form - `InfoForm` ,用以管理表單資料,定義一個 name 的字串輸入欄, label 取為 “ Name ”。
```
# app/server/main/forms.py
# -*- coding: utf-8 -*-
from flask_wtf import FlaskForm
from wtforms import StringField
from wtforms.validators import DataRequired
class InfoForm(FlaskForm):
name = StringField(
u'Name',
validators=[
DataRequired(u'請輸入姓名。')
]
)
```
WTForms 可以幫我們檢驗使用者輸入內容是否合乎標準,而 [validators](http://flask.pocoo.org/docs/0.12/patterns/wtforms/) 內就可以設定此標準。我們加入 [DataRequired](http://wtforms.readthedocs.io/en/latest/validators.html#wtforms.validators.DataRequired) 檢測欄位是否為空,並設定 message 的值為 `'請輸入姓名。'`。
1. 編輯 `server/main/views.py` ,引入剛剛宣告的 `InfoForm` 。
```
from app.server.main.forms import InfoForm
```
1. 生成一個 InfoForm ,在 [render_template](http://flask.pocoo.org/docs/0.12/quickstart/#rendering-templates) 裡給予參數將 form 傳到網頁。
```
@main_blueprint.route('/')
def home():
form = InfoForm(request.form)
return render_template('main/index.html', form=form)
```
1. 編輯 `client/templates/main/index.html` ,呼叫傳進來的 form 。
```
<div>
Hello Card Generator<br />
{{ form.name.label }} {{ form.name }}
</div>
```
#### 成果

### 傳送表單
現在要將使用者輸入的名稱傳至下一個結果頁面,就需要傳送表單到下一個頁面。
1. 在 `server/main/forms.py` 內新增一個 `submit` 的按鈕。
```
from wtforms import SubmitField
...
class InfoForm(FlaskForm):
submit = SubmitField('OK')
```
1. 修改 `client/templates/main/index.html` ,新增一個 [SubmitField](http://wtforms.simplecodes.com/docs/0.6.1/fields.html#wtforms.fields.SubmitField) 按鈕。
```
<form id="form" action="{{ url_for('main.home') }}" method="post">
{{ form.name.label }} {{ form.name }}
{{ form.submit }}
</form>
```
1. 修改 `server/main/views.py` ,新增 [methods](https://www.tutorialspoint.com/flask/flask_http_methods.htm) ,當請求是 POST 時,將導入結果頁面 `result.html`。
```
@main_blueprint.route('/', methods=['GET', 'POST'])
def home():
form = InfoForm(request.form)
if request.method == 'POST':
return render_template('main/result.html', form=form)
return render_template('main/index.html', form=form)
```
1. 新增 `client/templates/main/result.html` ,用來呈現輸入資料後的結果頁面。
```
{% extends "_base.html" %}
{% block content %}
<div>
{{ form.name.data }}
</div>
{% endblock %}
```
#### 成果

### 完成更多輸入欄位
Hello Card 除了要求姓名外,還需要使用者輸入信箱、部門、職位、聯絡資訊、想說的話等等,因此我們要將 InfoForm 新增更多的輸入欄位。
1. 修改 `server/main/forms.py` ,加入更多欄位,並在必填欄位加入 [DataRequired](http://wtforms.readthedocs.io/en/latest/validators.html#wtforms.validators.DataRequired) 的驗證,信箱部分加上 [Email](http://wtforms.readthedocs.io/en/latest/validators.html#wtforms.validators.Email) 的驗證。
```
from wtforms.widgets import TextArea
from wtforms.validators import Email
...
class InfoForm(FlaskForm):
name = StringField(
u'Name',
validators=[
DataRequired(u'請輸入姓名。')
]
)
email = StringField(
u'Email',
validators=[
DataRequired(u'請輸入信箱。'),
Email(u'信箱格式錯誤。')
],
render_kw={'placeholder': 'xxxxxx@kkbox.com'}
)
department = StringField(
u'Department',
validators=[
DataRequired(u'請輸入部門。')
]
)
position = StringField(
u'Position',
validators=[
DataRequired(u'請輸入職位。')
]
)
fb_account = StringField(
u'FB Account'
)
skp_account = StringField(
u'Skype Account'
)
ig_account = StringField(
u'Instagram Account'
)
other = StringField(
u"Finally, I'd like to say",
widget=TextArea()
)
submit = SubmitField('OK')
```
1. 在網頁 `client/templates/main/index.html` 將 form 的欄位顯示出來, templates 的 form 可以[設定標籤屬性](http://wtforms.simplecodes.com/docs/1.0.2/fields.html#wtforms.fields.Field.__call__), `other` 給定 cols 與 rows 的值,設定欄位大小。
```
<form id="form" action="{{ url_for('main.home') }}" method="post">
{{ form.name.label }} {{ form.name }}<br />
{{ form.email.label }} {{ form.email }}<br />
{{ form.department.label }} {{ form.department }}<br />
{{ form.position.label }} {{ form.position }}<br />
{{ form.fb_account.label }} {{ form.fb_account }}<br />
{{ form.ig_account.label }} {{ form.ig_account }}<br />
{{ form.skp_account.label }} {{ form.skp_account }}<br />
{{ form.other.label }} {{ form.other(cols="8", rows="10") }}<br />
{{ form.submit }}
</form>
```
1. 修改結果頁面 `client/templates/main/result.html` 。
```
<div>
{{ form.name.data }}<br />
{{ form.email.data }}<br />
{{ form.department.data }}<br />
{{ form.position.data }}<br />
{{ form.fb_account.data }}<br />
{{ form.ig_account.data }}<br />
{{ form.skp_account.data }}<br />
{{ form.other.data }}
</div>
```
#### 成果
 :arrow_right: 
### [驗證表單](https://wtforms.readthedocs.io/en/latest/validators.html)
WTForms 會為表單欄位檢測,若有與 validators 項目標準不符,則可將 message 警示顯示在表單上告知使用者,確認全部欄位都驗證通過後才成功送出表單。
1. 在 `server/main/views.py` 引入 `flash` ,在 view 內進行表單驗證,若是有[錯誤訊息](http://flask.pocoo.org/snippets/12/)則 [flash](http://flask.pocoo.org/docs/0.12/patterns/flashing/) 到網頁。
```
from flask import flash
...
@main_blueprint.route('/', methods=['GET', 'POST'])
def home():
form = InfoForm(request.form)
if request.method == 'POST':
if form.validate():
return render_template('main/result.html', form=form)
else:
for errors in form.errors.values():
for error in errors:
flash(error)
return render_template('main/index.html', form=form)
```
1. 修改 `client/templates/main/index.html` ,將錯誤訊息顯示在送出按鈕上方。
```
...
<div>
{% with messages = get_flashed_messages() %} {% if messages %}
{% for message in messages %}
{{ message }}
{% endfor %}
{% endif %} {% endwith %}
</div>
{{ form.submit }}
```
#### 成果

## [Semantic-UI](https://semantic-ui.com/) 裝飾頁面
現在的頁面十分簡陋,要將網頁變得賞心悅目,Semantic UI 是一個語意化的前端 UI 框架,和 Bootstrap 一樣都能將網頁變漂亮。 [Semantic-UI](https://semantic-ui.com/) 列出了所有美化的使用方法,依據所需的需求使用即可。
1. 到 [Semantic-UI Github](https://github.com/Semantic-Org/Semantic-UI/tree/master/dist) 的 `/dist` 下,下載 `semantic.min.js` 與 `semantic.min.css` 這兩個檔案,放在 `app/client/static` 的資料夾內,專案的靜態資源皆放在這個資料夾內,包括 css 、 js 以及圖檔。
1. 編輯 `client/templates/_base.html` ,引入 css 檔。
```
<!-- styles -->
<link href="{{url_for('static', filename='semantic.min.css')}}" rel="stylesheet">
```
在最下方引入 js , semantic 需要 jquery ,所以必須在引入 jquery 後再引入 semantic 。
```
<!-- scripts -->
<script src="https://code.jquery.com/jquery-3.1.1.min.js" integrity="sha256-hVVnYaiADRTO2PzUGmuLJr8BLUSjGIZsDYGmIJLv2b8=" crossorigin="anonymous"></script>
<script src="{{url_for('static', filename='semantic.min.js')}}" type="text/javascript"></script>
<script src="{{url_for('static', filename='main.js')}}" type="text/javascript"></script>
```
### 美化頁面
1. 修改 `client/templates/main/index.html` ,套入 class, 並依據[前面提過](https://docs-appdev.kkinternal.com/s/ByPUMC1O-#完成更多輸入欄位)的第二點,利用[設定標籤屬性](http://wtforms.simplecodes.com/docs/1.0.2/fields.html#wtforms.fields.Field.__call__)為`submit` 設定 class 樣式 。
```
<div class="ui middle aligned center aligned grid">
<div class="column" style="margin-top: 20px;">
<h2 class="ui teal image header">
Hello Card Generator
</h2>
</div>
</div>
<div class="ui middle aligned center aligned grid">
<div class="center floated left aligned column">
<div class="ui stacked segment">
<form id="form" class="ui large form" action="{{ url_for('main.home') }}" method="post">
<div class="required field">
{{ form.name.label }} {{ form.name }}
</div>
<div class="required field">
{{ form.email.label }} {{ form.email }}
</div>
<div class="required field">
{{ form.department.label }} {{ form.department }}
</div>
<div class="required field">
{{ form.position.label }} {{ form.position }}
</div>
<div class="field">
{{ form.fb_account.label }} {{ form.fb_account }}
</div>
<div class="field">
{{ form.ig_account.label }} {{ form.ig_account }}
</div>
<div class="field">
{{ form.skp_account.label }} {{ form.skp_account }}
</div>
<div class="field">
{{ form.other.label }} {{ form.other(cols="8", rows="10") }}
</div>
<div class="field">
{% with messages = get_flashed_messages() %} {% if messages %}
<div class="ui form error">
<div class="ui error message">
<div class="header">
Submission failed
</div>
<ul class="list">
{% for message in messages %}
<li> {{ message }}</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %} {% endwith %}
</div>
{{ form.submit(class="ui fluid large teal submit button") }}
</form>
</div>
</div>
</div>
```
1. 修改 `client/templates/main/result.html` ,套入 class 。
```
<div class="ui middle aligned center aligned grid" style="height: 95%;">
<div class="column">
<div class="ui centered card">
<div class="content" style="text-align: left;">
<a class="header">{{ form.name.data }}</a>
<div class="description">
{{ form.name.data }} 擔任 {{ form.department.data }} 的 {{ form.position.data }}
<div>
<a href="https://www.facebook.com/{{ form.fb_account.data }}">
facebook/{{ form.fb_account.data }}
</a>
</div>
<div>
<a href="https://www.instagram.com/{{ form.ig_account.data }}">
instagram/{{ form.ig_account.data }}
</a>
</div>
<div>
{{ form.skp_account.data }}
</div>
</div>
</div>
<div class="extra content" style="text-align: left;">
{{ form.other.data }}
</div>
</div>
</div>
</div>
```
1. 修改 `client/static/main.css` ,加上背景顏色及表單欄位寬度設定。
```
body {
background-color: #FCFCFC;
}
.column {
max-width: 450px;
}
```
#### 成果



### 靜態資源快取問題
也許有些人在剛剛有注意到:修改程式碼但網頁卻沒有改變,這是遇上 Static Resources Cache 的問題,因為快取時間還沒到期,即便 css 、 js 與圖檔已經更新了,使用者看見的仍是舊的內容,若要立即看到最新內容,我們可以透過清除快取或是使用 `Ctrl + F5` 強制重新載入頁面,但每次都要執行額外步驟來解決快取問題實在有點麻煩, Flask 在官網上有解決 [static url cache buster](http://flask.pocoo.org/snippets/40/) 的教學,直接在伺服器端透過修改快取時間來解決根本問題。
1. 在 `server/__init__.py` 加上程式碼。
```
from flask import url_for
...
######################
#### static cache ####
######################
@app.context_processor
def override_url_for():
return dict(url_for=dated_url_for)
def dated_url_for(endpoint, **values):
if endpoint == 'static':
filename = values.get('filename', None)
if filename:
file_path = os.path.join(os.path.dirname(app.root_path), "client", endpoint, filename)
values['q'] = int(os.stat(file_path).st_mtime)
return url_for(endpoint, **values)
```
### 加入 Icon
我們想要在結果頁面再加一點 Icon 使頁面更漂亮。
1. 到 [Icon | Semantic UI](https://semantic-ui.com/elements/icon.html) ,點選左上角 Download 下載 UI Framework 。
1. 解壓縮後將 `dist/themes/` 的資料夾放至 `client/static` 內。
1. 修改 `client/templates/main/result.html` ,加入 icon 。
```
<div class="ui centered card">
<div class="content" style="text-align: left;">
<a class="header">{{ form.name.data }}</a>
<div class="description">
<i class="user icon"></i>
{{ form.name.data }} 擔任 {{ form.department.data }} 的 {{ form.position.data }}
<div>
<i class="facebook square icon"></i>
<a href="https://www.facebook.com/{{ form.fb_account.data }}">
facebook/{{ form.fb_account.data }}
</a>
</div>
<div>
<i class="instagram icon"></i>
<a href="https://www.instagram.com/{{ form.ig_account.data }}">
instagram/{{ form.ig_account.data }}
</a>
</div>
<div>
<i class="skype icon"></i>
{{ form.skp_account.data }}
</div>
</div>
</div>
<div class="extra content" style="text-align: left;">
<i class="quote left icon"></i>
{{ form.other.data }}
<i class="quote right icon"></i>
</div>
</div>
```
#### 成果

## 隱藏未輸入的欄位
當非必要欄位使用者輸入空白時,輸出的卡片卻還是顯示該欄位,佔據了位置。我們要使用 [jinja2 的 if](http://jinja.pocoo.org/docs/2.9/templates/#whitespace-control) 來判別資料是否存在,若存在才顯示。
- 修改 `client/templates/main/result.html` 。
```
...
{% if form.fb_account.data %}
<div>
<i class="facebook square icon"></i>
<a href="https://www.facebook.com/{{ form.fb_account.data }}">
facebook/{{ form.fb_account.data }}
</a>
</div>
{% endif %}
{% if form.ig_account.data %}
<div>
<i class="instagram icon"></i>
<a href="https://www.instagram.com/{{ form.ig_account.data }}">
instagram/{{ form.ig_account.data }}
</a>
</div>
{% endif %}
{% if form.skp_account.data %}
<div>
<i class="skype icon"></i>
{{ form.skp_account.data }}
</div>
{% endif %}
</div>
</div>
{% if form.other.data %}
<div class="extra content" style="text-align: left;">
<i class="quote left icon"></i>
{{ form.other.data }}
<i class="quote right icon"></i>
</div>
{% endif %}
```
#### 成果

## 增加 Dropdown List
現在要新增兩個 dropdown list 供使用者選擇所屬的公司事業群及地區。
1. 修改 `server/main/forms.py` ,引入 [SelectField](http://wtforms.simplecodes.com/docs/0.6.1/fields.html#wtforms.fields.SelectField) 。
```
from wtforms import SelectField
```
1. 加入兩個 SelectField 到 `InfoForm` 。
```
class InfoForm(FlaskForm):
...
business_unit = SelectField(
u'Business Unit',
choices=[('KKBOX', 'KKBOX'),
('KKTV', 'KKTV'),
('KKSTREAM', 'KKSTREAM'),
('KKFARM', 'KKFARM'),
('Coporate', 'Corporate')]
)
location = SelectField(
u'Location',
choices=[('Taipei', 'Taipei'),
('Kaohsiung', 'Kaohsiung'),
('Hong Kong', 'Hong Kong'),
('Japan', 'Japan'),
('Malaysia', 'Malaysia'),
('Singapore', 'Singapore')]
)
```
1. 修改 `client/templates/main/index.html` ,在 email 下方加入兩個 dropdown list ,並設定 class 。
```
<div class="required field">
{{ form.business_unit.label }}
{{ form.business_unit(class="ui fluid normal dropdown") }}
</div>
<div class="required field">
{{ form.location.label }}
{{ form.location(class="ui fluid normal dropdown") }}
</div>
```
1. 修改 `client/templates/main/result.html` ,加上 `location` 與 `business_unit` 。
```
<div class="description">
<i class="user icon"></i>
{{ form.name.data }} 在 {{ form.location.data }} 的 {{ form.business_unit.data }}<br />
擔任 {{ form.department.data }} 的 {{ form.position.data }}
...
```
#### 成果


### 連動 Dropdown List
如果使用者選了 Japan 的 KKFARM 是一件很奇怪的事情,因為 Japan 並沒有 KKFARM ,因此我們要將兩個 Dropdown list 做連動,選擇完公司事業群後,才會有地區的選項供選擇。我們參考[這裡](https://www.sanwebe.com/2013/05/select-box-change-dependent-options-dynamically),更改關聯結構為巢狀 [js 物件](https://www.w3schools.com/js/js_objects.asp)。
1. 新增 `server/main/association_option.py` ,用來存取關聯選項。
```
# app/server/association_option.py
KK_DICT = {
'KKBOX': {
'Taipei': 'Taipei',
'Kaohsiung': 'Kaohsiung',
'Hong Kong': 'Hong Kong',
'Japan': 'Japan',
'Malaysia': 'Malaysia',
'Singapore': 'Singapore'
},
'KKTV': {
'Taipei': 'Taipei'
},
'KKSTREAM': {
'Taipei': 'Taipei',
'Kaohsiung': 'Kaohsiung'
},
'KKFARM': {
'Taipei': 'Taipei'
},
'Corporate': {
'Taipei': 'Taipei',
'Kaohsiung': 'Kaohsiung'
}
}
def init_business_unit():
return [(k, k) for k in KK_DICT]
def init_location():
location_set = reduce(lambda x, y: x | y, [set(i.keys()) for i in KK_DICT.values()])
return [(i, i) for i in location_set]
```
1. 修改 `server/main/forms.py` ,更改 dropdown list 的 choices 初始值。
```
from app.server.main.association_option import init_business_unit, init_location
...
business_unit = SelectField(
u'Business Unit',
validators=[
DataRequired()
],
choices=init_business_unit()
)
location = SelectField(
u'Location',
validators=[
DataRequired()
],
choices=init_location()
)
```
1. 修改 `server/main/views.py` ,將 `KK_DICT` 傳至 js 。
```
from app.server.main.association_option import KK_DICT
...
return render_template('main/index.html', form=form, KK_DICT=KK_DICT)
```
1. 在 `client/templates/main/index.html` 的 `endblock` 上方接收 `KK_DICT` 。
```
<script>
let kkDict = {{ KK_DICT|tojson|safe }}
</script>
{% endblock %}
```
1. 修改 `client/static/main.js` ,加上控制欄位連動的程式碼。我們使用 [in operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/in) 取出 `KK_DICT` 的 key 和 value 來連動 location 的選項。
```
$('#business_unit').prepend("<option value=''>--- Select One ---</option>").val(0)
$('#location').empty().prepend("<option value=''>--- Select One ---</option>").val(0)
$('.ui.dropdown').dropdown()
$('#business_unit').change(() => {
$('#location').dropdown('restore defaults')
let locationOptions = ''
for (let key in kkDict[$('#business_unit').val()]) {
let value = kkDict[$('#business_unit').val()][key]
locationOptions += `<option value='${value}'>${key}</option>`
$('#location').dropdown('set selected', value)
}
$('#location').empty().append(locationOptions)
})
```
#### 成果


## 上傳照片
新增一個讓使用者上傳大頭照的功能。
### 上傳至指定目錄
讓使用者在選擇圖片後,使用 ajax 將圖檔上傳至 server 。
1. 在 `server/config.py` 的 `BaseConfig` 內設定圖片儲存位置、允許檔案格式。
```
"""UPLOAD SETTINGS"""
PROJECT_DIR = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
UPLOAD_FOLDER = os.path.join(PROJECT_DIR, 'uploads')
```
1. 在專案根目錄下新增一個資料夾 `uploads` 。
1. 修改 `server/main/forms.py` ,加上 [FileField](http://flask-wtf.readthedocs.io/en/stable/form.html#validation) 。
```
from flask_wtf.file import FileField, FileAllowed, FileRequired
...
class InfoForm(FlaskForm):
photo = FileField(
u'Photo',
validators=[
FileRequired(u'請上傳照片。'),
FileAllowed(['jpg', 'png'], u'只能上傳圖檔。')
]
)
```
1. 在 `client/templates/index.html` 的 form 裡產生上傳欄位與上傳進度,並在 `form` 標籤內加上 [enctype](https://www.w3schools.com/tags/att_form_enctype.asp) 的屬性, `img-name` 用來存取存在 server 的圖檔名稱。
```
<form id="form" class="ui large form" action="{{ url_for('main.home') }}" method="post" enctype="multipart/form-data">
<div class="required field">
{{ form.photo.label }}
{{ form.photo }}
<input type="hidden" name="img-name" id="img-name">
<div id="photo-progress" class="ui bottom attached teal progress">
<div class="bar"></div>
</div>
</div>
```
1. 修改 `client/static/main.css` ,新增進度條的樣式,用 [transition](https://www.w3schools.com/css/css3_transitions.asp) 達到淡出的效果。
```
#photo-progress {
opacity: 0;
transition: opacity .25s ease-in-out;
-moz-transition: opacity .25s ease-in-out;
-webkit-transition: opacity .25s ease-in-out;
}
```
1. 在 `server/main/views.py` 檔裡,引入套件與 app 。
```
from hashlib import md5
from datetime import datetime
from flask import jsonify
from werkzeug.utils import secure_filename
from werkzeug.datastructures import CombinedMultiDict
from app.server import app
```
1. 新增一個上傳至伺服器的函式,會依據上傳檔案名稱與上傳時間用 [md5](https://docs.python.org/2/library/md5.html) 算出一個 hash 名稱,作為存在 server 的檔案名稱。
```
def upload_to_server(img_file):
if img_file:
img_name = (md5(secure_filename(img_file.filename) + str(datetime.now())).hexdigest()
+ img_file.content_type.replace('image/', '.'))
img_path = os.path.join(app.config['UPLOAD_FOLDER'], img_name)
img_file.save(img_path)
return img_name
```
1. 修改 home 的 form ,用 [CombinedMultiDict](http://flask-wtf.readthedocs.io/en/stable/form.html#module-flask_wtf.file) 將兩者合併。
```
form = InfoForm(CombinedMultiDict((request.files, request.form)))
```
1. 新增一個 route ,用來上傳圖片至 server ,用 [jsonify](https://www.programcreek.com/python/example/58915/flask.jsonify) 回傳檔案名稱給網頁。
```
@main_blueprint.route('/upload_photo', methods=['POST'])
def upload_photo():
img_name = upload_to_server(request.files['photo'])
return jsonify(img_name=img_name)
```
1. 修改 `client/static/main.js` ,當使用者選擇檔案後,呼叫 [ajax](http://codehandbook.org/python-flask-jquery-ajax-post/) ,並使用 [xhr](http://www.jianshu.com/p/716d470d6434) 處理進度條,事件成功後會回傳上傳至 server 後的圖檔名稱。
```
$('#photo').on('change', function () {
$.ajax({
url: '/hello-card-generator/upload_photo',
type: 'POST',
data: new FormData($('#form')[0]),
cache: false,
processData: false,
contentType: false,
xhr: () => {
let xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
$('#photo-progress').progress({
percent: Math.round(e.loaded * 100 / e.total)
})
}
})
return xhr
},
beforeSend: () => {
$('#photo-progress').removeClass('error')
$('#photo-progress').css('opacity', '1')
},
success: (data) => {
if (!data) {
$('#photo-progress').addClass('error')
}
$('#img-name').val(data.img_name)
},
complete: () => {
setTimeout(() => {
$('#photo-progress').css('opacity', '0')
}, 1000)
}
})
})
```
#### 成果



### Script root
在 js 內使用 ajax 時, url 若是寫死路徑會造成問題,像剛剛所寫的 ajax 就是將路徑寫死。如果今天伺服器的名字改名,不叫做 hello-card-generator 時,這一段程式碼就會出現錯誤。
Flask 有篇 [AJAX with jQuery](http://flask.pocoo.org/docs/0.10/patterns/jquery/#where-is-my-site) 的教學,可以找到正確的 script root ,如此一來就算伺服器名稱更改,也不用再變動 ajax 的程式碼。
1. 在 `client/templates/index.html` 下方新增一段 script ,讀取使用者的 script root ,當使用者網址為 `http://127.0.0.1:12345/hello-card-generator/` 時,抓出的 SCRIPT_ROOT 會為 `/hello-card-generator` 。
```
<script>
let SCRIPT_ROOT = {{ request.script_root|tojson|safe }}
</script>
```
1. 修改 `client/static/main.js` 內 ajax 的 url ,使用 `SCRIPT_ROOT` 來取得正確的網址。
```
url: SCRIPT_ROOT + '/upload_photo'
```
### [自定義驗證表單](http://wtforms.readthedocs.io/en/latest/validators.html#custom-validators)
我們不希望使用者上傳超過 3MB 的圖檔,但目前 [WTForms 的官方驗證](https://github.com/wtforms/wtforms/blob/master/wtforms/validators.py)中,並沒有提供檔案大小的驗證,所以我們必須自己寫一個驗證,此驗證參考 [pynuts/validators](https://pypkg.com/pypi/pynuts/f/pynuts/validators.py) 。
1. 在 `server/main/forms.py` 內加上一個自定義驗證。
```
from wtforms import ValidationError
...
class MaxSize(object):
def __init__(self, size=3):
self.max_size = size
def __call__(self, form, field):
if not field.has_file():
return
if self.byte_size < self.stream_size(field.data.stream):
raise ValidationError(u'上傳大小請不要超過 %.1f MB 。' % (self.max_size))
@property
def byte_size(self):
return self.max_size * 1048576
@staticmethod
def stream_size(stream):
if hasattr(stream, 'getvalue'):
file_size = len(stream.getvalue())
else:
stream.seek(0, 2)
file_size = stream.tell()
stream.seek(0)
return file_size
```
1. 為 `photo` 加上驗證。
```
validators=[
FileRequired(),
FileAllowed(['jpg', 'png'], u'只能上傳圖檔。'),
MaxSize()
]
```
#### 成果

### 預覽圖
使用者選擇圖檔後,要在上方顯示出預覽圖。
1. 在 `client/templates/index.html` 內新增一個預覽圖的標籤。
```
<img id="preview">
{{ form.photo.label }}
{{ form.photo }}
```
1. 在 `client/static/main.css` 內新增預覽圖的 css 。
```
#preview {
max-width: 150px;
max-height: 150px;
display: block;
margin: auto;
}
```
1. 修改 `client/static/main.js` ,處理圖檔上傳後的事件。預覽方法使用 Web APIs 的 [FileReader](https://developer.mozilla.org/en-US/docs/Web/API/FileReader) 來實現。`reader.readAsDataURL(this.files[0])` 會告知檔案是哪一個,產生一個 Data URI,這行執行完後就會被 onload,然後才會執行 onload 裡面的事件。
```
$('#photo').on('change', function () {
if (this.files && this.files[0]) {
let reader = new FileReader()
reader.onload = (e) => $('#preview').attr('src', e.target.result)
reader.readAsDataURL(this.files[0])
}
})
```
#### 成果

### 上傳至 Imgur
為了方便傳送圖片至 Email 或 Slack ,我們將圖片上傳至 Imgur 取得連結,並且在結果頁面顯示圖檔。在此可以從表單取得已上傳到 server 的圖檔名稱,將該名稱的檔案上傳至 Imgur 。
1. `$ pip install imgurpython` 安裝 `imgurpython` 套件。
1. 按照 [API doc](https://apidocs.imgur.com/) 申請 imgur api key 。
1. 在 `server/config.py` 的 `BaseConfig` 內設定 Imgur 的資訊。
```
"""IMGUR SETTINGS"""
IMGUR_CLIENT_ID = '***************'
IMGUR_CLIENT_SECRET = '******************************************'
IMGUR_ACCESS_TOKEN = '******************************************'
IMGUR_REFRESH_TOKEN = '******************************************'
IMGUR_ALBUM_ID = '*****'
```
1. 修改 `server/main/views.py` 檔,引入 ImgurClient 套件。
```
from imgurpython import ImgurClient
```
1. 新增上傳圖片的函式,給予圖檔名稱,回傳上傳圖片的連結。
```
def upload_to_imgur(img_name):
client = ImgurClient(
app.config['IMGUR_CLIENT_ID'],
app.config['IMGUR_CLIENT_SECRET'],
app.config['IMGUR_ACCESS_TOKEN'],
app.config['IMGUR_REFRESH_TOKEN']
)
config = {
'album': app.config['IMGUR_ALBUM_ID'],
'name': img_name,
'title': 'New Comer',
'description': 'Uploaded on {0}'.format(datetime.now())
}
img_path = os.path.join(app.config['UPLOAD_FOLDER'], img_name)
imgur_saved_img = client.upload_from_path(img_path, config=config, anon=False)
return imgur_saved_img['link']
```
1. 修改 `home()` ,呼叫上傳 imgur 的函式,將並將回傳的連結傳到網頁。
```
if request.method == 'POST':
img_name = request.form.get('img-name')
if form.validate():
img_link = upload_to_imgur(img_name)
return render_template('main/result.html', form=form, img_link=img_link)
```
1. 修改 `client/templates/main/result.html` ,在卡片最上放將接收到的圖檔顯示出來。
```
<div class="ui centered card">
<div class="image">
<img src="{{ img_link }}" width="200">
</div>
<div class="content" style="text-align: left;">
...
```
#### 成果


## 傳送 Hello Card 到 Slack
[Slack Bot for WebHook](https://github.com/satoshi03/slack-python-webhook) 可以從 python 執行程式,傳送指定的訊息到 Slack 。
需要有 webhook url 才能將訊息傳送到 slack , PET 已經有提供測試頻道的 webhook url 可用,也可以自己申請一個 channel ,在 channel 裡面申請 webhook 。
1. `$ pip install slackweb` 安裝 `slackweb` 。
1. 在 `server/config.py` 的 `BaseConfig` 內設定 webhook 。
```
"""SLACK SETTINGS"""
WEBHOOK_URL = 'https://hooks.slack.com/services/*********/*********/************************'
```
1. 於 `server/main/views.py` 內引入套件,傳送的文字包含中文,需要再檔案上方設定編碼方式,並新增一個傳訊息到 slack 的函式,關於 slackweb 更詳盡的教學可閱讀附錄的 [Slack Bot for WebHook](https://docs-appdev.kkinternal.com/s/ByPUMC1O-#slack-bot-for-webhook) 。
```
# -*- coding: utf-8 -*-
import slackweb
...
def send_slack(form, image_url, username='Hello Card', icon_emoji=':tada:', color='#439FE0'):
title = u':sparkles: 新 人 來 囉 :sparkles:'
text = u'_熱烈歡迎!_'
fields = []
for field in form:
if field.data and field.type in ['StringField', 'SelectField']:
if field.name == 'fb_account':
fields.append({
"title": field.label.text,
"value": u'facebook.com/{}'.format(field.data),
"short": 1
})
elif field.name == 'ig_account':
fields.append({
"title": field.label.text,
"value": u'instagram.com/{}'.format(field.data),
"short": 1
})
else:
fields.append({
"title": field.label.text,
"value": field.data,
"short": 1
})
attachments = []
attachment = {
"title": title,
"text": text,
"fields": fields,
"color": color,
"image_url": image_url,
"mrkdwn_in": ['text']
}
attachments.append(attachment)
slack = slackweb.Slack(url=app.config[u'WEBHOOK_URL'])
slack.notify(attachments=attachments, username=username, icon_emoji=icon_emoji)
```
1. 處理完圖檔後呼叫 `send_slack` 。
```
img_link = upload_to_imgur(img_name)
send_slack(form, img_link)
```
#### 成果

## 寄出 Hello Card 到 Email
從 python 執行程式,將 Hello Card 寄送到 email 。
1. 使用自己的 Gmail 設為 SMTP Server 。
- 到自己的 Gmail,點右上角的齒輪,點選【設定】。
- 找到【轉寄和POP/IMAP】,選擇【啟用IMAP】,按儲存變更。
- 再到[這裡](https://myaccount.google.com/lesssecureapps)開啟權限。
1. 安裝 [Flask-Mail](https://pythonhosted.org/Flask-Mail/) 。
`$ pip install Flask-Mail`
1. 在 `server/config.py` 的 `BaseConfig` 內設定,改為自己的 Gmail 帳號和密碼。
```
"""MAIL SETTINGS"""
MAIL_SERVER='smtp.gmail.com'
MAIL_USE_SSL=False
MAIL_USE_TLS=True
MAIL_DEFAULT_SENDER=('admin', '********@gmail.com')
MAIL_USERNAME='*******@gmail.com'
MAIL_PASSWORD='********'
```
1. 新增 `client/templates/main/email.html` 。
```
<!DOCTYPE html>
<head>
<title>Hello Card</title>
<meta charset="utf-8">
</head>
<body>
<form action="" method="post">
<table style="margin: 20px;">
<tr>
<th style="margin:0 auto;">
<h1>Hello Card</h1>
<p><em>Nice to meet you!</em></p>
<img src="{{ img_link }}" width="300">
<h3><strong>{{ form.name.data }}</strong></h3>
<p>{{ form.name.data }} 在 {{ form.location.data }} 的 {{ form.business_unit.data }}</p>
<p>擔任 {{ form.department.data }} 的 {{ form.position.data }}</p>
{% if form.other.data %}
<p>
想對大家說:{{ form.other.data }}
</p>
{% endif %}
{% if form.fb_account.data %}
<p>
<img src="http://i.imgur.com/e4wwC62.png" width="30">
facebook.com/{{ form.fb_account.data }}
</p>
{% endif %}
{% if form.ig_account.data %}
<p>
<img src="http://i.imgur.com/x6YyZRc.png" width="30">
instagram.com/{{ form.ig_account.data }}
</p>
{% endif %}
{% if form.skp_account.data %}
<p>
<img src="http://i.imgur.com/5TcxHPN.png" width="30">
{{ form.skp_account.data }}
</p>
{% endif %}
</th>
</tr>
</table>
</form>
</body>
</html>
```
1. 修改 `server/main/views.py`,引入套件並新增寄信函式,會[寄送郵件](https://pythonhosted.org/Flask-Mail/#sending-messages)到接收者信箱內。
```
from flask_mail import Mail, Message
...
def send_email(form, img_link):
subject = 'Hello Card'
sender = app.config['MAIL_DEFAULT_SENDER']
recipients = ['******@gmail.com']
html = render_template('main/email.html', form=form, img_link=img_link)
msg = Message(sender=sender, subject=subject, recipients=recipients, html=html)
mail = Mail(app)
mail.send(msg)
```
1. 處理完圖檔後呼叫 `send_email` 。
```
img_link = upload_to_imgur(img_path, img_file.filename)
send_slack(form, img_link)
send_email(form, img_link)
return render_template('main/result.html', form=form, img_link=img_link)
```
#### 成果

## WTForms 自定義 widget: Checkbox
我們希望能提供選項讓使用者選擇要不要傳訊息到 Slack 與要傳送到哪些部門的 Email,因此可以加上 Checkbox 供使用者選擇。[這裏](https://gist.github.com/doobeh/4668212)提供用官方預設的 widget 來產生 Checkbox,但使用此方法會產生 [Lists 的 Checkbox](https://jsfiddle.net/q0u04bj3/728f3moq/1/),我們並不希望出現 `<ul>` 及 `<li>` ,所以需要自定義 widget。
1. 在 `server/main/forms.py` 內新增一個[自定義的 widget](https://wtforms.readthedocs.io/en/latest/widgets.html#custom-widgets) 。
```
from wtforms.widgets import HTMLString, html_params
...
class SelectCheckbox(object):
def __call__(self, field, **kwargs):
html = ['']
for val, label, selected in field.iter_choices():
html.append(self.render_option(field.name, val, label, selected))
return HTMLString(u''.join(html))
@classmethod
def render_option(cls, name, value, label, selected):
options = {u'value': value}
if selected:
options['checked'] = u'checked'
return HTMLString(
u'<div class="ui checkbox"> \
<input type="checkbox" checked=True name="%s" %s> </input> <label>%s</label> \
</div><br>' % (name, html_params(**options), escape(unicode(label)))
)
```
1. 在 `InfoForm` 內新增兩個剛剛定義的 `SelectCheckbox` 。
```
from wtforms import SelectMultipleField
from app.server import app
try:
from html import escape
except ImportError:
from cgi import escape
...
class InfoForm(FlaskForm):
department_email = SelectMultipleField(
u'Send email to',
choices=[('a@gmail.com', 'a@gmail.com'), ('b@gmail.com', 'b@gmail.com')],
widget=SelectCheckbox()
)
slack_webhook = SelectMultipleField(
u'Post hello card to',
choices=[(app.config[u'WEBHOOK_URL'], 'Pet-test channel')],
widget=SelectCheckbox()
)
```
1. 在 `client/templates/main/index.html` 的 other 後面加上 `department_email` 與 `slack_webhook` 。
```
<div class="field">
{{ form.other.label }} {{ form.other(cols="8", rows="10") }}
</div>
{% if form.department_email.choices %}
<div class="field">
{{ form.department_email.label }} {{ form.department_email }}
</div>
{% endif %}
{% if form.slack_webhook.choices %}
<div class="field">
{{ form.slack_webhook.label }} {{ form.slack_webhook }}
</div>
{% endif %}
```
1. 修改 `server/main/views.py` ,檢測使用者是否有勾選, `form.department_email.data` 與 `form.slack_webhook.data` 皆會回傳一陣列。
```
if form.slack_webhook.data:
send_slack(form, img_link)
if form.department_email.data:
send_email(form, img_link)
```
1. 修改 `send_slack` ,依照勾選的 webhook 傳送。
```
for url in form.slack_webhook.data:
slack = slackweb.Slack(url=url)
slack.notify(attachments=attachments, username=username, icon_emoji=icon_emoji)
```
1. 將 `send_email` 的 `recipients` 改為 `form.department_email.data` 的陣列。
```
recipients = form.department_email.data
```
#### 成果

## 自動化測試
每次寫完程式都要重新在手動測試一遍相當耗費時間,因此我們可以寫自動化測試讓程式幫我們去測試成果是不是如預期。
1. 安裝 `Flask-Testing` 。
`$ pip install Flask-Testing`
1. 在 `app/server/config.py` 加入 test 的 config 設定。
```
class TestingConfig(BaseConfig):
"""Testing configuration."""
DEBUG = True
WTF_CSRF_ENABLED = False
TESTING = True
```
1. 在 `app` 底下新增資料夾 `tests` ,用來放我們的測試文件。
1. 新增 `tests/__init__.py` , `__init__.py` 可以不需要有任何內容,但需要這個檔案,直譯器才能辨識此資料夾為套件,可在此檔案內加入一行註解。
```
# app/tests/__init__.py
```
1. 在 `manage.py` import 套件並定義 test 指令,之後可使用 `$ python manage.py test` 測試 `tests/` 內所有 test 開頭的檔案。
```
import unittest
...
@manager.command
def test():
"""Runs the unit tests."""
tests = unittest.TestLoader().discover('app/tests', pattern='test*.py')
result = unittest.TextTestRunner(verbosity=2).run(tests)
if result.wasSuccessful():
return 0
return 1
```
### 環境 config 測試
1. 新增 `app/tests/test_config.py` ,並 import 套件。
```
import unittest
from flask import current_app
from flask_testing import TestCase
from app.server import app
```
1. 新增 Development 環境下的測試,首先需要創建出 app ,設定該環境下的 config。
```
class TestDevelopmentConfig(TestCase):
def create_app(self):
app.config.from_object('app.server.config.DevelopmentConfig')
return app
```
1. 透過 [current_app](http://flask.pocoo.org/docs/0.12/appcontext/#flask.current_app) 可檢查現在的 app 狀態。
比如:`current_app.config['TESTING']` 會回傳布林值,檢查現在的 app 是否設定於 TESTING 的 config 下。 `app.config['****']` 可以直接取得該 config 的 value。
```
def test_app_is_development(self):
self.assertEqual(current_app.config['TESTING'], False)
self.assertEqual(app.config['DEBUG'], True)
self.assertEqual(app.config['WTF_CSRF_ENABLED'], False)
```
1. 以此類推,我們同樣加入 Testing 及 Production 的 config 檢測,並於底下新增執行函式。
```
class TestTestingConfig(TestCase):
def create_app(self):
app.config.from_object('app.server.config.TestingConfig')
return app
def test_app_is_testing(self):
self.assertEqual(current_app.config['TESTING'], True)
self.assertEqual(app.config['DEBUG'], True)
self.assertEqual(app.config['WTF_CSRF_ENABLED'], False)
class TestProductionConfig(TestCase):
def create_app(self):
app.config.from_object('app.server.config.ProductionConfig')
return app
def test_app_is_production(self):
self.assertEqual(current_app.config['TESTING'], False)
self.assertEqual(app.config['DEBUG'], False)
self.assertEqual(app.config['WTF_CSRF_ENABLED'], True)
if __name__ == '__main__':
unittest.main()
```
### 網頁測試
1. 新增 `app/tests/test_hello_card.py` ,並 import 套件。
```
import unittest
from flask_testing import TestCase
from app.server import app
```
1. 新增網頁測試,首先需要創建出 app ,設定該環境下的 config 。
```
class TestHelloCard(TestCase):
def create_app(self):
app.config.from_object('app.server.config.TestingConfig')
return app
```
1. 測試網頁正常開啟,會導向一個 `/` 的 route , `status_code` 是回應訊息的狀態碼,正常開啟為 200 。測試特定文字是否成功顯示在網頁上,可檢查產生出的 HTML 頁面 `response.data` 內是否有該字串。
```
def test_index(self):
response = self.client.get('/')
self.assertEqual(response.status_code, 200)
self.assertEqual('Hello Card Generator' in response.data, True)
```
1. 最後同樣需要加入執行函式。
```
if __name__ == '__main__':
unittest.main()
```
### 網頁表單測試
測試時需要 post 測試值時可以參考[這裡](https://github.com/realpython/flask-skeleton/blob/master/project/tests/test_user.py)。
- 透過 `client.post()`,可以把測試值 POST 到網頁的表單裡,然後搜尋結果頁面 `response.data` 內是否與預期測試值相同。
```
def test_fill_out_form(self):
response = self.client.post(
'/',
data = dict(
name='test',
email='test@test.com',
department='test',
position='test',
fb_account='test',
skp_account='test',
ig_account='test',
other='test',
business_unit='KKBOX',
location='taipei',
department_email='a@gmail.com',
slack_webhook='https://hooks.slack.com/services/*********/*********/************************'
),
follow_redirects=True
)
self.assertEqual('test' in response.data, True)
```
### 網頁檔案上傳測試
測試時需要 post 檔案時可以參考[這裡](https://stackoverflow.com/questions/28276453/cant-unit-test-image-upload-with-python)。
1. 如果 HTML 檔裡的上傳檔案欄位是 `<input type="file" name="photo">`
1. 那你可以 import StringIO 套件,讀取標籤屬性 `name="photo"` 的 test.jpg ,並透過 `client.post()` 把讀取到的檔案 POST 到表單裡。
```
from StringIO import StringIO
...
def test_upload_image(self):
with open (/path/to/test.jpg) as test:
img_string_io = StringIO(test.read())
response = self.client.post(
'/',
content_type='multipart/form-data',
data = dict(
{'photo': (img_string_io, 'uploaded_test.jpg')}
),
follow_redirects = True
)
self.assertEqual(response.status_code, 200)
```
## [pylint](https://www.pylint.org/) 檢查程式碼
要用人工逐步檢查程式碼是否符合 Coding Style 是相當費時的,`pylint` 是 Python 程式碼的檢查工具,可以幫我們[檢查程式碼](https://pylint.readthedocs.io/en/latest/user_guide/run.html),省下檢查的時間。`pylint` 有高度的自定義設定,可以決定自己的檢查標準。
#### 安裝
1. 安裝 `pylint` 。
`$ pip install pylint`
1. 產生 rcfile 到專案目錄下,並取名為 pylintrc ,目前的 rcfile 已經是基本的設定,可以自己設定檢查標準, PET 部門有自己的規範,套用相同設定即可。
`$ pylint --generate-rcfile > ./pylintrc`
#### 使用教學
1. `$ pylint **.py` 就可以檢查 `**.py` ,會跑出訊息告知有什麼地方需要修改,將程式碼修改到滿分。
```
************* Module project.tests.test_hello_card
R: 28, 4: Too many arguments (9/5) (too-many-arguments)
------------------------------------------------------------------
Your code has been rated at 9.68/10 (previous run: 9.35/10, +0.32)
```
#### [Inline disable](https://docs.pylint.org/en/latest/faq.html#is-there-a-way-to-disable-a-message-for-a-particular-module-only)
在某些情況下,程式碼不得不違反規定,並且沒有更佳的寫法,這時可以使用 pylint inline disabled comment 來避免 `pylint` 檢測。例如在某些字串本身就很長的情況下,會造成 line-too-long 的問題,可以在該行程式碼後方加上 disable 解決。
```
'channel': 'abcdefghijklmnopqrstuvwxyz...' # pylint: disable=line-too-long
```
但這種 disable 要避免濫用,在真的沒有其他更佳解法下才使用,否則檢測就會變得沒有意義了。
#### [File disable](https://docs.pylint.org/en/latest/faq.html#how-can-i-tell-pylint-to-never-check-a-given-module)
有些時候專案裡會夾雜著他人撰寫的程式碼,並不想要列入檢測範圍,這時可以使用 pylint file disabled comment 來避免檢測。只要在該檔案的最上方加入 `# pylint: skip-file` 即可跳過檔案。
# 附錄
## Coding Style
寫程式的人都或多或少會有這種感覺,別人的程式碼看起來總是覺得怪怪的,這是因為每個人寫程式都有自己的風格,這在團隊開發上會造成麻煩,寫程式不僅僅只是要自己看得懂,也要讓他人好上手,因此 Coding Style 就佔有非常大的重要性。
每個語言有各自的程式設計風格,下面三份資料是介紹 Coding Style 的方法:
1. [Python Coding Style](http://docs.python-guide.org/en/latest/writing/style/)
2. [Javascript Coding Style](https://standardjs.com/rules.html)
3. [HTML Coding Style](https://www.w3schools.com/html/html5_syntax.asp)
Coding Style 不是必須的,每個團隊的風格也不盡相同,但若團隊有共同的 Coding Style ,對於團隊開發效率會大幅提升。
### 重點整理
#### Python
1. 強制縮排為四格。
```
def hello():
print hello;
```
1. 命名方法。
| Type | Example |
|-----------|---------------------------------|
| Module | module_name.py |
| Package | package_name |
| Class | class ClassName(object): |
| Exception | class ExceptionName(Exception): |
| Function | def function_name(): |
| Method | def method_name(self): |
| Constant | Constant |
| Variable | var_name = 1 |
#### Javascript
1. 縮排為兩格。
```
function hello (name) {
console.log('hi', name)
}
```
1. 命名使用 camelCase 。
```
let myValue = ...
```
1. 宣告變數使用 `let` 或 `const` 取代 `var` 。
1. 用 `===` 取代 `==` , `!==` 取代 `!=` 。
#### HTML
- 命名方式全小寫,若須連接以 - 號連接。
```
<img id='my-img' ... />
```
### pylint
要用人工逐步檢查程式碼是否符合 Coding Style 是相當費時的,`pylint` 是 Python 程式碼的檢查工具,可以幫我們檢查程式碼,省下檢查的時間。`pylint` 有高度的自定義設定,可以決定自己的檢查標準。
#### 安裝
1. `$ pip install pylint` 安裝 `pylint` 。
1. `$ pylint --generate-rcfile > ./pylintrc`
產生 rcfile 到專案目錄下,並取名為 pylintrc ,目前的 rcfile 已經是基本的設定,可以自己設定檢查標準, PET 部門有自己的規範,套用相同設定即可。
#### 使用教學
- `$ pylint **.py` 就可以檢查 `**.py` ,會跑出訊息告知有什麼地方需要修改。
```
************* Module project.tests.test_hello_card
R: 28, 4: Too many arguments (9/5) (too-many-arguments)
------------------------------------------------------------------
Your code has been rated at 9.68/10 (previous run: 9.35/10, +0.32)
```
#### Inline disable
在某些情況下,程式碼不得不違反規定,並且沒有更佳的寫法,這時可以使用 pylint inline disabled comment 來避免 `pylint` 檢測。
例如在某些字串本身就很長的情況下,會造成 line-too-long 的問題,可以在該行程式碼後方加上 disable 解決。
```
'channel': 'abcdefghijklmnopqrstuvwxyz...' # pylint: disable=line-too-long
```
但這種 disable 要避免濫用,在真的沒有其他更佳解法下才使用,否則檢測就會變得沒有意義了。
#### File disable
有些時候專案裡會夾雜著他人撰寫的程式碼,並不想要列入檢測範圍,這時可以使用 pylint file disabled comment 來避免檢測。
只要在該檔案的最上方加入 `# pylint: skip-file` 即可跳過檔案。
## 虛擬環境 Virtualenv
### 安裝 Virtualenv
- `$ pip install virtualenv` 在 termianal 裡下指令來安裝。
> pip 是 Python 的套件管理工具,它集合下載、安裝、升級、管理、移除套件等功能,藉由統一的管理,可以使我們事半功倍,更重要的是,也避免了手動執行上述任務會發生的種種錯誤。 pip 的安裝非常簡單,請前往 [pip 的官方文件網站](http://pip.readthedocs.org/en/latest/installing.html) 。
### 使用 Virtualenv
1. `$ virtualenv venv`
在我們想要新增虛擬環境的專案資料夾下新增一個 `venv` , `venv` 是我們取的一個名字,可隨自己喜好。
1. `$ source venv/bin/activate`
啟動虛擬環境,就會發現 terminal 上多了一個虛擬使用者 `(venv)` ,表示啟動成功。 若是用 fish 的人會發現無法啟動,可以改打成 `$ source venv/bin/activate.fish` 。
1. 啟動成功後就可以像平常一樣下指令安裝。
1. `$ pip list`
可以列出目前有安裝的套件,可以查看有啟動虛擬環境與尚未啟動的差別。
1. 若有第三方程式庫,都可以在 `venv/lib/Python2.7/site-packages` 裡看到已下載好的。
1. `$ deactivate` 退出虛擬環境。
### 安裝 requirments.txt
- `$ pip install -r requirements.txt`
很多專案都會將所需的套件列在 `requirments.txt` 裡面,要一個一個安裝太過於麻煩,可以下指令安裝在 `requirments.txt` 裡的所有套件。
`requirments.txt` 裡面大致長這樣,會有相對應的套件名稱與版本:
```
coverage==4.3.4
Flask==0.12
Flask-Script==2.0.5
AllPairs==2.0.1
```
### 注意事項
1. 通常 push 到 GitHub 上的專案都不會包括虛擬環境,需要用 `.gitignore` 來避免虛擬環境被 push 上去,可以參考本篇文章 Git 的教學。
1. 做專案時請將所有會用到的套件及版本都列在 `requirments.txt` ,方便其他人開專案時使用。
1. 沒有用到的套件就不需要安裝,可以讓專案輕一點。
### 參考資料
- [Django筆記 - Python的模組與套件 / DOKELUNG'S BLOG](http://dokelung-blog.logdown.com/posts/243281-notes-django-python-modules-and-kits)
## [Git](https://git-scm.com/)
### Git 與 Github
有些人可能分不清楚 git 和 Github 的差別。
git 是一個分散式版本控制軟體,不需要伺服器端軟體,就可以運作版本控制,使得原始碼的釋出和交流極其方便。
Github 是一個透過 git 進行版本控制的軟體原始碼代管服務的公司,有免費的空間供使用者使用,但若是要將專案設成不公開,就要另行付費。
### 安裝 git
- `$ apt-get install git`
使用 `apt-get` 安裝 `git` ,爾後才能在 terminal 執行 `git` 的指令。
### 公開金鑰認證 SSH key
公開金鑰認證(Public Key Authentication),讓使用者不必輸入密碼即可直接登入 Linux,既安全又方便。
1. `$ ssh-keygen`
產生一個新的 SSH key,系統會詢問一些問題,對於一般的使用者而言,全部都使用預設值按下 Enter 即可。
1. `$ cat ~/.ssh/id_rsa.pub`
讀取金鑰,要把這段文字用在 Github 設定。 id_rsa 是產生 SSH key 預設的名字,如果有另外設定的話記得要改成該名字。
1. 到 Github 的設定頁,左邊欄位有一個 SSH and GPG keys ,到該頁面後按下 New SSH key 然後把剛剛讀取到的金鑰貼上即可。
### 常用指令
#### 下載檔案
1. `$ git clone [SSH / HTTP 網址]` 下載檔案。
1. `$ git clone -b [branch 名稱] [SSH / HTTP 網址]` 下載某個 branch 。
#### 新增檔案
1. `$ git add .` 新增所有檔案。
1. `$ git add file.txt` 新增 `file.txt` 這個檔案。
#### 撰寫日誌
`commit` 就像是寫日誌,在檔案上傳前必須的一個步驟,告訴別人此次上傳做了什麼修改。
1. `$ git commit -am "日誌內容"` 幫所有檔案寫日誌。
1. `$ git commit -m "日誌內容" file.txt` 幫 `file.txt` 寫日誌。
#### 上傳檔案
`$ git push origin master` 或 `$ git push` 皆可。
以上是一定會用到的幾個步驟,其他指令可以參閱 [Git 教學(1) : Git 的基本使用 / 好麻煩部落格](https://gogojimmy.net/2012/01/17/how-to-use-git-1-git-basic/)。
### 忽略檔案 gitignore
在開發專案的時候,有些東西是必須規避上傳到 Github 上,例如一些自動生成的檔案。如下列:
| 檔案 | 解說 |
|--------------|---------------------------------|
| virtualenv | 虛擬環境 |
| .DS_Store | Mac OS X 作業系統所創造的隱藏文件 |
| .pyc | python的編譯文件 |
#### 編輯 .gitignore
1. `$ vim .gitignore`
在專案資料夾下新增編輯一個 `.gitignore` 的檔案。執行後會跑到 vim 編輯畫面。
1. 輸入需要忽略的檔案,以下為範例,箭頭後面的只是說明,無須輸入至檔案內。
```
venv/ <- 虛擬環境資料夾
**/*.DS_Store <- 所有目錄下的所有 .DS_Store 檔案
**/*.pyc <- 所有目錄下的所有 .pyc 檔案
```
#### 注意事項
1. 應該在專案尚未上傳至 Github 前先行規避檔案,在開始執行 add 、 commit 、 push 等指令。
1. 若已經上傳到 Github 上的檔案,就算規避了也不會自動刪除已經上傳的檔案,可以先行刪除,在使用 gitignore 忽略檔案。
## [Flask](http://flask.pocoo.org/)
Flask 是一個使用 Python 撰寫的輕量級網頁應用程式框架,主要是由 [Werkzeug WSGI](http://werkzeug.pocoo.org/) 工具箱和 [Jinja2 模板引擎](http://jinja.pocoo.org/docs/2.9/)所組成。
### 使用教學
1. `$ mkdir my-website` 建立專案資料夾。
1. `$ virtualenv venv` 建置虛擬環境。
1. `$ source venv/bin/activate` 啟動虛擬環境。
1. `$ pip install Flask` 安裝 Flask 套件。
1. `$ vim hello.py` 編輯 hello 的 python 檔案,打入範例程式碼:
```
from flask import Flask
app = Flask(__name__)
@app.route("/")
def hello():
return "Hello World!"
```
1. `$ FLASK_APP=hello.py flask run` 執行 flask ,可以打開 http://localhost:5000/ 查看成果。
1. `Ctrl + C` 終止程式。
### 基本框架 flask-skeleton
寫 Flask 可以自己從空的資料夾開始安裝環境、建置專案,也可以參考其他專案。
比如: [flask-skeleton](https://github.com/realpython/flask-skeleton) ,它已包含了 Flask 的基本框架。
#### 使用教學
1. `$ git clone https://github.com/realpython/flask-skeleton.git` 下載專案。
1. `$ cd flask-skeleton` 移動到專案資料夾內。
1. `$ virtualenv venv` 建置虛擬環境。
1. `$ source venv/bin/activate` 啟動虛擬環境。
1. `$ pip install -r requirements.txt` 安裝 `requirements.txt` 內的所有套件。
1. `$ export APP_SETTINGS="project.server.config.DevelopmentConfig"` 設定環境變數,此為開發模式。
1. `$ python manage.py runserver`開啟網頁伺服器後,可以打開 http://localhost:5000/ 查看成果。
1. `Ctrl + C` 終止程式。
從 `requirements.txt` 內可以發現這專案包含了許多套件,若是在開發上沒有需要這些功能,可以進行刪減的動作,避免專案太累贅。
#### 解說架構
可以看到 projects 裡面包含了 client、server 與 tests ,簡單說明大致功能。
| client | 解說 |
|-----------|----------------------------|
| static | 放置 js、css 或圖片等 |
| templates | 放置 html |
| server | 解說 |
|---------------|----------------------------|
| 資料夾 | 用於分類網頁 |
| main/views.py | 用 python 處理完資料後傳給網頁 |
| tests | 解說 |
|---------------|----------------------------|
| test_**.py | unit test 單元測試 |
#### Static resource cache
寫 javascript 時遇上修改程式碼,但網頁卻沒有改變時,就是遇上靜態資源 cache 的問題,因為快取時間還沒到期,即便 css 、 js 與圖檔已經更新了,使用者看見的仍是舊的內容,若要立即看到最新內容,我們可以透過清除快取或是使用 `Ctrl + F5` 強制重新載入頁面,但每次都要執行額外步驟來解決快取問題實在有點麻煩, Flask 在官網上有解決 [static url cache buster](http://flask.pocoo.org/snippets/40/) 的教學,直接在伺服器端透過修改快取時間來解決根本問題。
- 在 `server/__init__.py` 加上程式碼。
```
from flask import Flask, render_template, url_for
...
@app.context_processor
def override_url_for():
return dict(url_for=dated_url_for)
def dated_url_for(endpoint, **values):
if endpoint == 'static':
filename = values.get('filename', None)
if filename:
file_path = os.path.join(os.path.dirname(app.root_path), "client", endpoint, filename)
values['q'] = int(os.stat(file_path).st_mtime)
return url_for(endpoint, **values)
```
#### Script root
在 Javascript 內使用 ajax 時, url 若是寫死路徑會造成問題,如下方這個案例:
```
let request = $.ajax({
type: 'GET',
url: '/hello-card-generator/' + bu_id
})
```
如果今天伺服器的名字改名,不叫做 hello-card-generator 時,這一段程式碼就會出現錯誤。
Flask 有篇 [AJAX with jQuery](http://flask.pocoo.org/docs/0.10/patterns/jquery/#where-is-my-site) 的教學,可以找到正確的 script root ,如此一來就算伺服器名稱更改,也不用再變動 ajax 的程式碼。
1. 在 html 內新增一段 script ,讀取使用者的 script root ,當使用者網址為 `http://127.0.0.1:12345/hello-card-generator/` 時,抓出的 SCRIPT_ROOT 會為 `/hello-card-generator` 。
```
<script type=text/javascript>
$SCRIPT_ROOT = {{ request.script_root|tojson|safe }};
</script>
```
1. 在 ajax 的 url 中使用 script root 來取得正確的網址。
```
let request = $.ajax({
type: 'GET',
url: SCRIPT_ROOT + '/' + bu_id
})
```
## [Jinja2](http://jinja.pocoo.org/docs/2.9/)
當我們要從 python 傳資料到 html 或是 js 時,透過 Jinja2 就可以很方便的呼叫資料。
### 使用教學
#### 傳遞資料至 html
以 [flask-skeleton](https://github.com/realpython/flask-skeleton) 為基礎架構,將首頁原先的 “ Welcome! ” 修改成 “ Welcome, PET. ”,而 PET 是我們從 python 傳過去的變數。
1. 打開 `project/server/main/views.py` ,可以看到網頁首頁的程式碼。
```
@main_blueprint.route('/')
def home():
return render_template('main/home.html')
```
1. 傳一個 name 的資料給 `home.html` 。
```
@main_blueprint.route('/')
def home():
name = 'PET'
return render_template('main/home.html', name=name)
```
1. 在 html 裡需要顯示出接收到的資料,開啟 `project/client/templates/main/home.html` ,修改顯示的字樣。
```
<h1>Welcome, {{ name }}!</h1>
```
#### 傳送資料至 js
1. 在 `project/client/templates/main/home.html` 下方新增一個 script 。
```
<script>
let strs = {{ strs }}
</script>
```
1. 傳送一個 dictionary 。
```
@main_blueprint.route('/')
def home():
dicts = {'a': 1, 'b': 2}
return render_template('main/home.html', dicts=dicts)
```
```
<script>
let dicts = {{ dicts }}
</script>
```
結果執行後出現錯誤,發現出現以下的問題。
預期:
```
let dicts = {"a": 1, "b": 2}
```
實際:
```
let dicts = {'a': 1, 'b': 2}
```
4. 使用 [Standard Filters](http://flask.pocoo.org/docs/0.12/templating/#standard-filters) ,加上 `|tojson|safe` 解決問題, `tojson` 將物件轉為 JSON 形式, `safe` 關閉 html 自動轉義。
```
<script>
let dicts = {{ dicts|tojson|safe }}
</script>
```
> 自動轉義是指自動對特殊字符進行轉義。特殊字符是指 HTML 中的 & 、 > 、 < 、 " 和 ' 。因為這些特殊字符代表了特殊的意思,所以如果要在文本中使用它們就必須把它們替換為實體。如果不轉義,那麼使用者就無法使用這些字符,而且還會帶來安全問題。
示範有無 safe 的差別,可以發現自動轉義會將特殊符號轉成實體字符,若是沒有自動轉義,程式會有安全上的問題:
1. 傳送一個 `html = "<p>Hello.</p>"` 到網頁。
1. 自動轉義 `{{ html }}` : `<p>Hello.</p>`
1. 關閉自動轉義 `{{ html|safe }}` : `Hello.`
#### 使用迴圈
如果有資料需要使用迴圈輸出,如陣列等, Jinja2 也可以做到。
1. 修改 `project/server/main/views.py` 。
```
@main_blueprint.route('/')
def home():
strs = ['a', 'b', 'c', 'd', 'e']
return render_template('main/home.html', strs=strs)
```
1. 修改 `project/client/templates/main/home.html` ,加入以下程式碼。
```
{% for str in strs %}
<p>{{ str }}</p>
{% endfor %}
```
#### [built-in pipe filter](http://jinja.pocoo.org/docs/2.9/templates/#builtin-filters)
傳資料到網頁後,可以透過 `Jinja2` 做更多的處理,以下是一個簡單的範例。
1. 從 Flask 傳一個陣列到網頁。
```
@main_blueprint.route('/')
def home():
nums = [1, 2, 3, 4, 5]
return render_template('main/home.html', nums=nums)
```
1. 在網頁輸出 `nums` 。
```
{{ nums }}
```
輸出結果:[1, 2, 3, 4, 5]
1. 在網頁使用 built-in pipe filter 。
```
{{ nums|join(', ') }}
```
輸出結果:1, 2, 3, 4, 5
```
{{ nums|sum() }}
```
輸出結果:15
還有更多好用的 built-in pipe filter 可以見[官網說明](http://jinja.pocoo.org/docs/2.9/templates/#builtin-filters)。
## [uwsgi](https://uwsgi-docs.readthedocs.io/en/latest/index.html)
### 簡介
`uwsgi` 用來快速部署 Python 應用,Python 應用之前的部署很麻煩,比較常用的方法有 `fcgi` 與 `wsgi`,然而這兩種都很讓人頭痛,`uwsgi` 的出現解決了這些麻煩。
uWSGI 的主要特點如下:
1. 超快的性能。
1. 低內存占用(實測為 apache2 的 mod_wsgi 的一半左右)。
1. 多 app 管理。
1. 詳盡的日誌功能(可以用來分析 app 性能和瓶頸)。
1. 高度可定製(內存大小限制,服務一定次數後重啟等)。
#### Nginx、uWSGI 和 Flask 之間的關係
客戶端從發送一個 HTTP 請求到 Flask 處理請求,分別經過了 web 伺服器層,WSGI層,web框架層,這三個層次。
Nginx 屬於一種 web 伺服器,Flask 屬於一種 web 框架,WSGI 就像一條紐帶,將 web 伺服器與 web 框架連接起來。

他們之間的對話就像是這樣:
> Nginx:Hey,WSGI,我剛收到了一個請求,我需要你作些準備,然後由 Flask 來處理這個請求。
WSGI:OK,Nginx。我會設置好環境變量,然後將這個請求傳遞給 Flask 處理。
Flask:Thanks WSGI!給我一些時間,我將會把請求的響應返回給你。
WSGI:Alright,那我等你。
Flask:Okay,我完成了,這裡是請求的響應結果,請求把結果傳遞給 Nginx。
WSGI:Good job!Nginx,這裡是響應結果,已經按照要求給你傳遞迴來了。
Nginx:Cool,我收到了,我把響應結果返回給客戶端。大家合作愉快!
### 使用教學
1. 以 [flask-skeleton](https://github.com/realpython/flask-skeleton) 為基礎架構。
1. `$ pip install uWSGI==2.0.12` 安裝 uWSGI ,最好是將 `uWSGI==2.0.12` 加至 `requirements.txt` 內再行安裝。
1. `$ vim dev.ini` 創建一個 ini 檔案,作為設定檔,輸入下列參數設定。
```
[uwsgi]
http = :12345
mount = /hello=run.py
manage-script-name = true
die-on-term = true
py-autoreload = 2
```
1. `$ vim run.py` 創建一個執行檔,輸入下方程式碼。
```
import logging
logging.basicConfig(filename='/tmp/hello.log', filemode='w', level=logging.DEBUG,
format='%(asctime)s [%(threadName)s] %(levelname)-6s %(name)s - %(message)s')
from project.server import app as application
```
1. `$ uwsgi --ini dev.ini` 開啟伺服器,可以打開 http://localhost:12345/hello/ 查看成果。
可以發現網址和一開始使用 `$ python manage.py runserver` 的 http://localhost:5000/ 不同,這些都是透過 `dev.ini` 設定而改變的。一台電腦有可能同時建置許多網頁伺服器,若路徑都相同會造成相衝,也為了能夠讓網址有意義,因此需要設定網址。
### 路徑設定問題
可以發現雖然是透過 http://localhost:12345/hello/ 查看網頁,但輸入 http://localhost:12345/ 竟然也能夠成功查看網頁,這是因為在預設下,如果載入網址沒有與 mountpoint 相符,將會被安裝為預設目錄(直接導入根目錄),為了解決此問題,我們需要在 `dev.ini` 裡加上一個設定 `no-default-app = true` ,再重新執行一次 uwsgi ,就發現問題解決了。
### 參考資料
1. [Python Web 部署方式總結](https://read01.com/K5xQdd.html#.WZKZp3cjFjQ)
1. [如何理解 Nginx、uWSGI 和 Flask 之間的關係?](https://read01.com/zh-tw/324PNN.html#.WZKZ0ncjFjQ)
## [Semantic-UI](https://semantic-ui.com/)
Semantic UI 是一個語意化的前端 UI 框架,和 Bootstrap 一樣都能將網頁變得賞心悅目。
### 下載 Semantic UI
下載方法有以下兩種:
1. 到 [Semantic-UI Github](https://github.com/Semantic-Org/Semantic-UI/tree/master/dist) 的 dist 資料夾下,可以看到 `semantic.min.js` 與 `semantic.min.css` ,下載這兩個檔案放置專案,在 html 引入即可開始使用,另外還需 jquery 。
1. `$ bower install semantic-ui` 使用 [bower](https://bower.io/) 安裝。使用 bower 的好處是他會連帶幫你安裝好 jquery ,專案下會出現一個 `bower_components` 的資料夾,裡面就有所需的 js 及 css 檔。
### 使用方法
[Semantic-UI](https://semantic-ui.com/) 列出了所有的使用方法,依據所需的需求使用即可。
## [Slack Bot for WebHook](https://github.com/satoshi03/slack-python-webhook)
可以從 python 執行程式,傳送指定的訊息到 Slack 。
### 安裝
- `$ pip install slackweb` 安裝 `slackweb` 。
### 使用教學
1. 在 python 檔裡 import 套件,並設定 slack 的 webhook。
```
import slackweb
slack = slackweb.Slack(url="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX")
```
1. 執行這行程式碼會傳送訊息至 slack 。
```
slack.notify(text="訊息內容")
```

1. slack bot 的許多東西都可以自定義,如名稱、大頭貼等。
```
slack.notify(text="訊息內容", username="bot 名稱", icon_emoji=":robot_face:")
```

1. 想要將訊息內容進行更多設定,可以使用 attachments ,以下是其中一個範例,建議查看 [Attaching content ](https://api.slack.com/docs/message-attachments) 會更了解有什麼可以設定。另外 slack API 也提供 [Message Builder](https://api.slack.com/docs/messages/builder) 讓使用者先在上面查看輸出到 slack 上的預覽畫面,就不會因為要修改樣式而洗版 slack 。
```
attachments = []
attachment = {
"title": "Sushi",
"pretext": "Sushi _includes_ gunkanmaki",
"text": "Eating *right now!*",
"mrkdwn_in": ["text", "pretext"]
}
attachments.append(attachment)
slack.notify(attachments=attachments)
```

## Send email
可以從 python 執行程式,傳送網頁到 email 。
### 前置作業
如果你想將你的 Gmail 設為 SMTP Server,你必須這麼做:
1. 到自己的 gmail,點右上角的齒輪,然後選【設定】。
1. 找到【轉寄和POP/IMAP】,選擇【啟用IMAP】,按儲存變更。
1. 到[這裡](https://myaccount.google.com/lesssecureapps)開啟權限。
### 安裝
- `$ pip install Flask-Mail` 安裝 Flask-Mail 。
### 使用教學
1. 在 config 檔裡設定 server,下面 ****** 的地方改為自己的 Gmail 帳號和密碼。
```
MAIL_SERVER='smtp.gmail.com'
MAIL_USE_SSL=True
MAIL_DEFAULT_SENDER=('admin', '********@gmail.com')
MAIL_USERNAME='*******@gmail.com'
MAIL_PASSWORD='********'
```
1. 在 python 檔裡 import 套件,並設定 email 的 app。
```
from flask import Flask
from flask_mail import Mail
app = Flask(__name__)
mail = Mail(app)
```
1. 設定寄件人,主旨,收件者以及傳送內容網頁。
```
sender = (admin, "from@gmail.com")
subject = "Hello Card"
recipients = ["to@gmail.com"]
html = "<b>hello</b>"
msg = Message(
sender=sender,
subject=subject,
recipients=recipients,
html=html
)
```
1. `mail.send(msg)` 傳送郵件。
### 注意事項
HTML Styling 請使用 Inline CSS,可避免 Web based E-Mail client 移除你的 CSS。
更多關於 [html 內容規範](https://www.benchmarkemail.com/tw/help-FAQ/answer/Common-HTML-Email-Coding-Mistakes)。
### 參考資料
1. [Flask-Mail 0.9.1 documentation](https://pythonhosted.org/Flask-Mail/)
1. [Flask-Mail - 使用 Python Flask 完成寄信功能](https://github.com/twtrubiks/Flask-Mail-example)
## Upload Image
### 上傳至指定目錄
1. 在 config 檔裡,設定圖片儲存位置、允許檔案格式。
```
# UPLOAD SETTINGS
PROJECT_DIR = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
UPLOAD_FOLDER = os.path.join(PROJECT_DIR, 'uploads')
ALLOWED_EXTENSIONS = set(['png', 'jpg', 'jpeg'])
```
1. 在 HTML 檔裡,產生輸入欄位。
```
<form action="{{ url_for('index') }}" method="post" enctype="multipart/form-data">
<input type="file" name="img-file">
<input type="submit" value="Upload">
</form>
```
1. 在 python 檔裡,我們需要:
- `from werkzeug import secure_filename` 引入確認檔名安全套件。
- 檢查副檔名是否符合允許格式。
```
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1] in app.config['ALLOWED_EXTENSIONS']
```
- `img_file = request.files['img-file']` 取得屬性 name="img-file" 的檔案。
- 檢查檔案是否存在且符合格式,如果是就移除不支援的檔名字符並保存於指定目錄下。
```
if img_file and allowed_file(img_file.filename.lower()):
img_name = secure_filename(img_file.filename)
img_path = os.path.join(app.config['UPLOAD_FOLDER'], img_name)
img_file.save(img_path)
```
### 上傳至 Imgur
1. 申請 [imgur api key](https://apidocs.imgur.com/) 之後,在 config 檔設定 Imgur 的 key 和上傳[相簿 ID](https://simonhsu.blog/2017/04/06/%E9%80%8F%E9%81%8E-imgur-api-%E5%8F%8A-google-cloud-functions-%E6%9E%B6%E6%A7%8B%E8%B6%85%E7%B0%A1%E6%98%93-line-%E5%9B%9E%E5%9C%96%E6%A9%9F%E5%99%A8%E4%BA%BA-%E9%80%A3%E5%9C%96%E7%89%87%E6%B5%81/) 。
```
# IMGUR SETTINGS
IMGUR_CLIENT_ID = '***************'
IMGUR_CLIENT_SECRET = '******************************************'
IMGUR_ACCESS_TOKEN = '******************************************'
IMGUR_REFRESH_TOKEN = '******************************************'
IMGUR_ALBUM_ID = '*****'
```
1. 在 python 檔裡,我們需要:
- import ImgurClient 套件與時間日期。
```
from imgurpython import ImgurClient
from datetime import datetime
```
- 設定 Imgur 的 client 。
```
client = ImgurClient(
app.config['IMGUR_CLIENT_ID'],
app.config['IMGUR_CLIENT_SECRET'],
app.config['IMGUR_ACCESS_TOKEN'],
app.config['IMGUR_REFRESH_TOKEN']
)
```
- 設定上傳相簿,圖片名稱,標題及描述。
```
config = {
'album': app.config['IMGUR_ALBUM_ID'],
'name': name,
'title': 'New Comer',
'description': 'Uploaded on {0}'.format(datetime.now())
}
```
- 存擋至 imgur,其中 img_path 為要上傳的圖片之路徑。
```
imgur_saved_img = client.upload_from_path(
img_path,
config=config,
anon=False
)
```
- `imgur_saved_img['link']` 可以取得圖片連結。
### 參考資料
1. [Uploading Files — Flask Documentation (0.12)](http://docs.jinkan.org/docs/flask/patterns/fileuploads.html)
1. [Imgur API](https://apidocs.imgur.com/)
## 測試
看完 [Unit Test / App 開發人員訓練手冊](https://appdev.kkinternal.com/trainingbook/PythonUnitTest.html)後已經有基本的 unit test 概念,以下講解偏向如何測試網頁的方法。
### 網頁測試
1. 測試網頁正常開啟,會戳向一個 `/` 的 route , `status_code` 是回應訊息的狀態碼,正常開啟為 200 。
```
def test_index(self):
response = self.client.get('/')
self.assertEqual(response.status_code, 200)
```
1. 若是 route 會再進行轉址,而我們想測到最後的成果頁面是否成功開啟,可以加上參數。
```
def test_index(self):
response = self.client.get('/', follow_redirects=True)
self.assertEqual(response.status_code, 200)
```
1. 測試網頁 title 是否符合預期。
```
def test_index(self):
response = self.client.get('/', follow_redirects=True)
title = self.get_html_title(response.data)
self.assertEqual(title, 'Hello Card Generator')
```
1. 測試某段文字是否成功在頁面上顯示,可以搜尋 `response.data` 內是否有該字串, `response.data` 是頁面的 HTML 。
```
def test_index(self):
response = self.client.get('/', follow_redirects=True)
self.assertEqual('Hello Card Generator' in response.data, True)
```
### 網頁表單測試
1. 如果你的 form 包含 StringField,SelectField 和 SelectMultipleField。
```
class MyForm(FlaskForm):
name = StringField(
u'Name',
validators=[
DataRequired(),
Length(min=1, max=25)
]
)
gender = SelectField(
u'Gender',
choices=()
)
interests = SelectMultipleField(
u'Interests',
choices=choices
)
```
1. 那麼你可以透過 `client.post()` 把測試值傳送到網頁的表單裡,然後搜尋 `response.data` 內是否有該測試值, `response.data` 是頁面的 HTML 。
```
def test_fill_out_form(self):
response = self.client.post(
'/',
data=dict(
name='test',
gender='test',
interests='test'
),
follow_redirects=True
)
self.assertIn(b'test', response.data)
```
### 網頁檔案上傳測試
測試時需 post 檔案時可以參考[這裡](https://stackoverflow.com/questions/28276453/cant-unit-test-image-upload-with-python)。
1. 如果你的 HTML 檔裡的上傳檔案欄位是 `<input type="file" name="photo">`
1. 那你可以在 python 檔裡 import StringIO 套件,讀取屬性 name="photo" 的 test.jpg ,並透過 `client.post()` 把讀取到的檔案傳送到網頁的表單裡。
```
from StringIO import StringIO
...
def test_upload_image(self):
with open(/path/to/test.jpg) as test:
img_string_io = StringIO(test.read())
response = self.client.post(
'/',
content_type='multipart/form-data',
data=dict(
{'photo': (img_string_io, 'uploaded_test.jpg')}
),
follow_redirects=True
)
self.assertEqual(response.status_code, 200)
```
### 注意事項
1. 一個好的測試盡量不受其他外界因素影響,例如說我們要測試飲料機投幣功能是否能正常運作,但卻因為機器沒有電而測試失敗,這不是因為投幣功能異常,而是因為沒有電而失敗,因此測試時應該先假設是有電的情況,才能正常測試投幣功能。
1. 一個測試不該有太多的 assertion ,原因是若前面的 assertion 失敗,後面的測試連跑都還沒跑就結束測試了,很難追究錯誤的原因。
1. 測試的步驟可以分為 3A:
Arrange:初始化目標物件、相依物件、方法參數、預期結果,或是預期與相依物件的互動方式。
Act:呼叫目標物件的方法。
Assert:驗證是否符合預期。
```
def test_index(self):
# Arrange
url = '/'
expected = 200
# Act
response = self.client.get(url)
# Assert
self.assertEqual(response.status_code, expected)
```