--- 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> ``` #### 成果 ![](https://i.imgur.com/j9oF8rz.png) ### 傳送表單 現在要將使用者輸入的名稱傳至下一個結果頁面,就需要傳送表單到下一個頁面。 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 %} ``` #### 成果 ![](https://i.imgur.com/KXXhTkl.png) ### 完成更多輸入欄位 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> ``` #### 成果 ![](https://i.imgur.com/CDSSjX3.png) :arrow_right: ![](https://i.imgur.com/pyj4VwE.png) ### [驗證表單](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 }} ``` #### 成果 ![](https://i.imgur.com/wrKHoAO.png) ## [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; } ``` #### 成果 ![](https://i.imgur.com/D4pXAYH.png) ![](https://i.imgur.com/EGbROgA.png) ![](https://i.imgur.com/8kCp6qN.png) ### 靜態資源快取問題 也許有些人在剛剛有注意到:修改程式碼但網頁卻沒有改變,這是遇上 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> ``` #### 成果 ![](https://i.imgur.com/p92SHYe.png) ## 隱藏未輸入的欄位 當非必要欄位使用者輸入空白時,輸出的卡片卻還是顯示該欄位,佔據了位置。我們要使用 [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 %} ``` #### 成果 ![](https://i.imgur.com/rSMlr0J.png) ## 增加 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 /> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 擔任 {{ form.department.data }} 的 {{ form.position.data }} ... ``` #### 成果 ![](https://i.imgur.com/8MIBamT.png) ![](https://i.imgur.com/QcpfbiG.png) ### 連動 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) }) ``` #### 成果 ![](https://i.imgur.com/xO4SfIj.png) ![](https://i.imgur.com/Mp5e77E.png) ## 上傳照片 新增一個讓使用者上傳大頭照的功能。 ### 上傳至指定目錄 讓使用者在選擇圖片後,使用 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) } }) }) ``` #### 成果 ![](https://i.imgur.com/nPizolC.png) ![](https://i.imgur.com/LfL08gu.png) ![](https://i.imgur.com/nrfYCPh.png) ### 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() ] ``` #### 成果 ![](https://i.imgur.com/bZAF7UB.png) ### 預覽圖 使用者選擇圖檔後,要在上方顯示出預覽圖。 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]) } }) ``` #### 成果 ![](https://i.imgur.com/4RW7VDa.png) ### 上傳至 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;"> ... ``` #### 成果 ![](https://i.imgur.com/47kjh0D.png) ![](https://i.imgur.com/2swYqmX.png) ## 傳送 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) ``` #### 成果 ![](https://i.imgur.com/WIm5bRE.png) ## 寄出 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) ``` #### 成果 ![](https://i.imgur.com/YvIDiqu.png) ## 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 ``` #### 成果 ![](https://i.imgur.com/Z3NrPgj.png) ## 自動化測試 每次寫完程式都要重新在手動測試一遍相當耗費時間,因此我們可以寫自動化測試讓程式幫我們去測試成果是不是如預期。 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 = {&#39;a&#39;: 1, &#39;b&#39;: 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 框架連接起來。 ![](https://i.imgur.com/53vqZ3O.jpg) 他們之間的對話就像是這樣: > 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="訊息內容") ``` ![](https://i.imgur.com/9Vdc5EE.png) 1. slack bot 的許多東西都可以自定義,如名稱、大頭貼等。 ``` slack.notify(text="訊息內容", username="bot 名稱", icon_emoji=":robot_face:") ``` ![](https://i.imgur.com/yJFnKSQ.png) 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) ``` ![](https://i.imgur.com/hxkNKgE.png) ## 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) ```