Flask - Email

Date: Apr. 28th, 2019

Lecturer: Chia

OUTLINE

  • 使用Flask-Mail擴充套件
    • 透過Python Shell寄送
    • 整合email與app
    • 寄送非同步email

學習目標:使用Flask app傳送email。

使用Flask-Mail擴充套件

1. 使用pip安裝Flask-Mail擴充套件

(venv) $ pip install flask-mail
  • 此擴充套件會連接SMTP伺服器。
  • SMTP是一種傳輸郵件的規範。

E-mail 要件

os.environ.get('MAIL_USERNAME')
os.environ.get('MAIL_PASSWORD')

sender

recipients

2. 連接外部的SMTP

  • 目標:使用Gmail帳號傳送email
  • 為了保護帳號資訊,故不把帳號資訊直接寫在腳本裡,而是從環境變數匯入
### db_demo.py:設置Flask-Mail來使用Gmail ###

import os
# ...
app.config['MAIL_SERVER'] = 'smtp.googlemail.com'
app.config['MAIL_PORT'] = 587
app.config['MAIL_USE_TLS'] = True
app.config['MAIL_USERNAME'] = os.environ.get('MAIL_USERNAME')
app.config['MAIL_PASSWORD'] = os.environ.get('MAIL_PASSWORD')

3. 設定Gmail以接受SMTP身份驗證

Google帳號設定網頁 -> 安全性 -> "低安全性應用程式存取權" 開啟!

4. 初始化Flask-Mail

### db_demo.py ###

from flask_mail import Mail
mail = Mail(app)

5. Windows cmd 設定環境變數

  • 匯入email伺服器使用者名稱與密碼的環境變數
    • 利用Gmail帳密,來使用Google的SMTP server
(venv) $ set MAIL_USERNAME=pcsh110576@gmail.com
(venv) $ set MAIL_PASSWORD=**************

透過Python Shell寄送

  • 目標:透過Python Shell測試設置的結果。
  • 做法:必須每次手動建立email訊息 (HTML/純文字)。
(venv) $ set FLASK_APP=db_demo.py
(venv) $ flask shell

>>> from flask_mail import Message
>>> from db_demo import mail
>>> msg = Message('test_email', sender='pcsh110576@gmail.com', 
                                recipients=['404040523@mail.fju.edu.tw'])
>>> msg.html = 'This is the <b>flask test</b> mail.'
>>> with app.app_context():
...     mail.send(msg)
>>>
>>> msg.body = 'This is the plain text body'

LAB - 1

  • 透過Python Shell寄送純文字訊息

整合email與app

  • 目標:寫成一個函式,以避免每次手動建立email訊息。
  • 優點:彈性高,且可將Jinja2模板轉譯成email內文。
from flask_mail import Message

#主旨的開頭文字
app.config['MAIL_SUBJECT_PREFIX'] = '[Flasky]'

#寄件者的地址,與SMTP Server相同帳號
app.config['MAIL_SENDER'] = 'Flasky Admin <pcsh110576@gmail.com>'

#send_email(收件人地址, 主旨, email內文模板, 關鍵字引數)
def send_email(to, subject, template, **kwargs):
    msg = Message(app.config['MAIL_SUBJECT_PREFIX'] + subject, 
                sender = app.config['MAIL_SENDER'], recipients=[to])
    msg.body = render_template(template + '.txt', **kwargs)
    mail.send(msg)
    msg.html = render_template(template + '.html', **kwargs)

E-mail 要件

os.environ.get('MAIL_USERNAME')
os.environ.get('MAIL_PASSWORD')

app.config['MAIL_SENDER']

app.config['MAIL_ADMIN']

  • 純文字模板

 

 

 

 

  • HTML模板

 

建立位置:templates/mail/new_user.txt

User {{ user.username }} has joined. (txt)
建立位置:templates/mail/new_user.html

User <b>{{ user.username }}</b> has joined. (html)

建立email內文模板

  • 目標:當收到表單送來的新名字時,寄email給管理者。
### db_demo.py ###
# ...

#收件者
app.config['MAIL_ADMIN'] = os.environ.get('FLASKY_ADMIN') 

@app.route('/name_form', methods=['GET', 'POST'])
def name_form():
    # ...
        session['known'] = False

        ### 新增
        if app.config['MAIL_ADMIN']: 
            send_email(app.config['MAIL_ADMIN'], 'New User', 'mail/new_user', user=user)
    else:
        session['known'] = True
    # ...

擴充view函式 (db_demo.py)

Windows cmd 設定環境變數

(venv) $ set MAIL_USERNAME=pcsh110576@gmail.com
(venv) $ set MAIL_PASSWORD=**************
(venv) $ set FLASKY_ADMIN=404040523@mail.fju.edu.tw

LAB - 2

使用HTML模板來寄送Email

先建立HTML模板,再設定環境變數(MAIL_USERNAMEMAIL_PASSWORDFLASKY_ADMIN),最終寄出Email。

寄送非同步email

  • 目標:將email寄送函式移往背景執行緒,以避免處理請求時產生延遲。
#當你要同時做很多事情時,就可以用到threading達成多執行緒。

from threading import Thread

def send_email(to, subject, template, **kwargs):
    msg = Message(app.config['MAIL_SUBJECT_PREFIX'] + subject, 
                  sender = app.config['MAIL_SENDER'], recipients=[to])
    msg.body = render_template(template + '.txt', **kwargs)
    #msg.html = render_template(template + '.html', **kwargs)
    thr = Thread(target=send_async_email, args=[app, msg])
    thr.start()
    return thr

def send_async_email(app, msg):
    with app.app_context():
	mail.send(msg)

Flask -

Large App Structure

Date: May. 12th, 2019

Lecturer: Chia

OUTLINE

  • App基本結構
    1. 組態設定選項config.py
    2. App套件
    3. App主腳本 db_demo.py
    4. requirements檔
    5. 單元測試
  • 執行App

App基本結構

C:\Users\pcsh1\flask_proj
│  config.py          --- 組態設定
│  db_demo.py         --- 定義Flask App實例 & 協助管理App的工作
│  requirements.txt   --- 套件依賴項目,以便重建一致的虛擬環境
│  
├─app          --- (app套件) Flask App
├─migrations   --- 資料庫遷移腳本   
├─tests        --- (tests套件) 單元測試
└─venv         --- Python虛擬環境
  • 學習目標:架構較大型的App,以增加擴充性。
  • 原因:db_demo.py腳本變大,不便使用。

組態設定選項 (config.py)

import os
basedir = os.path.abspath(os.path.dirname(__file__))

class Config:
    SECRET_KEY = os.environ.get('SECRET_KEY') or 'hard to guess string' 
    MAIL_SERVER = os.environ.get('MAIL_SERVER', 'smtp.googlemail.com')
    MAIL_PORT = int(os.environ.get('MAIL_PORT', '587'))
    MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ['true', 'on', '1']
    MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
    MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
    MAIL_SUBJECT_PREFIX = '[Flasky]'
    MAIL_SENDER = 'Flasky Admin <pcsh110576@gmail.com>'
    MAIL_ADMIN = os.environ.get('FLASKY_ADMIN')
    SQLALCHEMY_TRACK_MODIFICATIONS = False

    @staticmethod		#實作空的init_app()方法	
    def init_app(app):
        pass
  • Config基礎類別:所有組態共同的設定
class DevelopmentConfig(Config):
    DEBUG = True
    SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \
        'mysql://flaskdb_chia:gibe258deny700@localhost:3306/flaskdb'

class TestingConfig(Config):
    TESTING = True
    SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \
        'sqlite://'
    #預設為【記憶體內部資料庫】,測試完成後將不保留資料。

class ProductionConfig(Config):
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
        'sqlite:///' + os.path.join(basedir, 'data.sqlite')

config = {
    'development': DevelopmentConfig,
    'testing': TestingConfig,
    'production': ProductionConfig,

    'default': DevelopmentConfig #將Development組態,註冊為預設值
}
  • 子類別:分別定義特定組態專屬的設定
    • 分離Development、Testing與Production期間所使用的不同資料庫,以免相互干擾。

App套件

C:\Users\pcsh1\flask_proj
├─app
   │  email.py     --- email支援函式
   │  models.py    --- 資料庫模型
   │  __init__.py
   │  
   ├─main
   │  │  errors.py
   │  │  forms.py
   │  │  views.py
   │  │  __init__.py
   │          
   ├─static        --- 靜態檔案
   ├─templates     --- 模板
      │  404.html
      │  500.html
      │  base.html
      │  base_index.html
      │  webform.html
      │  
      └─mail
          │  new_user.html      
          │  new_user.txt

App套件

  • 使用App工廠 (app/__init__.py)
  • 在藍圖中實作App功能
    • 建立主藍圖
    • 註冊主藍圖
    • 主藍圖裡的錯誤處理函式
    • 主藍圖裡的app路由
    • 表單物件
  • app/email.py
  • app/models.py

使用App工廠

  • 原因:
    • 用單一檔案來建立App,等同在全域範圍內建立App,固然方便,但無法動態套用組態的改變
      • 亦即【已經幫你買好固定規格的家具】
  • 目的:
    • Allows different Flask applications to be created with different configurations.
    • 可建立多個App實例 (家具)
      • Useful during testing, where the configuration can often be different from development or production.

Application Factory

The steps of initialize a Flask project :

  • Creating the Flask application as an instance of the Flask class. (空房子)
  • Configuring the Flask application (設定家具規格)
  • Initializing the extensions to be used (家具)
  • Registering the Blueprints in the project (劃分房間)

 

These steps can be put together in a single function called an Application Factory function.

### app/__init__.py:App套件建構式 ###

##匯入多數目前正在使用的Flask擴充套件
from flask import Flask
from flask_bootstrap import Bootstrap
from flask_mail import Mail
from flask_moment import Moment
from flask_sqlalchemy import SQLAlchemy
#匯入config.py
from config import config
#尚未初始化
bootstrap = Bootstrap()
db = SQLAlchemy()
mail = Mail()
moment = Moment()

To start, import all of the modules (including the Flask extensions) that are needed for the Flask project:

Next, create the instances(實例) of each of the Flask extensions without initializing them:

#create_app()為"App工廠函式",回傳建立好的App實例。
def create_app(config_name): #config_name接收組態名稱,以讓App使用
    #建立App實例
    app = Flask(__name__)
    
    #將config.py內定義的組態類別,其所儲存的組態設定直接匯入App
    app.config.from_object(config[config_name])  
    
    #App初始化,使用init_app()
    config[config_name].init_app(app)
    bootstrap.init_app(app)
    db.init_app(app)
    mail.init_app(app)
    moment.init_app(app)

    ### 註冊主藍圖 ###
    # 在這裡指派"路由"與"錯誤頁面處理函式" #直到藍圖被app註冊時,才成為app的一部分。

    return app

Next, the actual Application Factory function (create_app) is defined:

  • Initialize each Flask extension.
  • Blueprints are registered with the Flask application.

你以為這樣就結束了嗎...

  • 路由 & 自定義錯誤頁面處理程式?

  • 下一節的主題 --- 藍圖

在藍圖中實作App功能

  • 原因:
    • 單一腳本app時,app實例位於全域範圍內,故能用app.route裝飾器來定義路由。
    • 現在app在非全域範圍內,故使用藍圖(blueprint)的方式來定義路由。
  • 藍圖(blueprint)
    • 可以定義路由錯誤處理函式
    • 當在藍圖裡定義時,處於休眠狀態。直到藍圖被app註冊時,才成為app的一部分。

App工廠的轉化工作,引出了路由的複雜化...

Blueprints

  • Allow you to cleanly organize the source code of your Flask application.

  • Each Blueprint encapsulates a significant piece of functionality in your application.
  • One project can have multiple Blueprints. (房間)

1. 建立主藍圖

  • 目標:在app套件內,建立子套件來乘載app的第一個藍圖。
    • 【我要建立一個房間】
  • 特別留意:main(藍圖名稱)必須先被定義,views、errors才能匯入main藍圖物件,否則將會匯入失敗。
### app/main/__init__.py ###

from flask import Blueprint

#實例化Blueprint類別。
#此類別的建構式必須接收兩個引數:藍圖名稱與藍圖所在的模組或套件。
main = Blueprint('main', __name__)

#匯入app套件的模組,以建立與藍圖的關係 (包含app路由&錯誤頁面處理函式)
# . 代表目前的套件; .. 代表目前套件的父代
from . import views, errors #相對匯入

2. 註冊主藍圖

  • 藍圖是使用create_app()工廠函式內的app來註冊。
  • 【我真的建立一個房間】
### app/__init__.py ###

# ...
    
    ### 註冊主藍圖 ###
    
    #連接到app/main/__init.py
    from .main import main as main_blueprint 
    app.register_blueprint(main_blueprint)
    
    return app

3. 主藍圖裡的錯誤處理函式

  • 若要設定遍及app範圍,則須改用app_errorhandler裝飾器。
### app/main/errors.py ###

from flask import render_template
from . import main

# 全域(app_errorhandler裝飾器)的錯誤處理函式 #

@main.app_errorhandler(404)     #路由裝飾器來自藍圖(main.route)
def page_not_found(e):
    return render_template('404.html'), 404
    
@main.app_errorhandler(500)
def internal_server_error(e):
    return render_template('500.html'), 500

4. 主藍圖裡的app路由

  • 在藍圖裡編寫view函式,差異有二:
    1. 路由裝飾器來自藍圖,故使用main.route。
    2. url_for()函式,改為套用藍圖名稱(main)的main.index。
  • 各個不同的藍圖可使用相同的端點名稱來定義view函式,不會造成衝突
    • main.index
    • core.index
from flask import render_template, session, redirect, url_for, flash, current_app
from datetime import datetime
from . import main
from .forms import NameForm #表單物件
from .. import db
from ..models import User
from ..email import send_email

### app/main/views.py ###

@main.route('/name_form', methods=['GET', 'POST'])     #路由裝飾器來自藍圖(main.route)
def name_form():
    form = NameForm()
    if form.validate_on_submit():
        # 用表單收到的name在資料庫中查詢
        user = User.query.filter_by(username=form.name.data).first()

        # 查無此姓名的話,將該姓名寫入資料庫
        if user is None:
            user = User(username=form.name.data)
            db.session.add(user)
            db.session.commit() #把表單所輸入的,寫進資料庫
            session['known'] = False
            if current_app.config['MAIL_ADMIN']:
                send_email(current_app.config['MAIL_ADMIN'], 'New User', 'mail/new_user', user=user)  ###
        else:
            session['known'] = True
        session['name'] = form.name.data
        form.name.data = ''

        old_name = session.get('name')
        if old_name is not None and old_name != form.name.data:
            flash('Looks like you have changed your name!')
            
        # 將命名空間套用到在藍圖中所定義的所有端點
        return redirect(url_for('main.name_form')) #等同於 '.name_form'
    return render_template('webform.html', form=form, name=session.get('name'), 
                                current_time=datetime.utcnow(), known=session.get('known', False))

5. 表單物件

from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired

class NameForm(FlaskForm):
	name = StringField('What is your name?', 
                    validators=[DataRequired()])
	
        submit = SubmitField('Submit') 

### app/main/forms.py ###

app/email.py

from threading import Thread
from flask import render_template, current_app ###
from flask_mail import Message
from . import mail

def send_email(to, subject, template, **kwargs):
	app = current_app._get_current_object() ###
	msg = Message(app.config['MAIL_SUBJECT_PREFIX'] + subject, 
                    sender = app.config['MAIL_SENDER'], recipients=[to])

	msg.body = render_template(template + '.txt', **kwargs)
	#msg.html = render_template(template + '.html', **kwargs)
	thr = Thread(target=send_async_email, args=[app, msg])
	thr.start()
	return thr

def send_async_email(app, msg):
	with app.app_context():
		mail.send(msg)

app/models.py

from . import db

class Role(db.Model):
	__tablename__ = 'roles'
	id = db.Column(db.Integer, primary_key = True)
	name = db.Column(db.String(64), unique = True)
	users = db.relationship('User', backref='role', lazy='dynamic')

	def __repr__(self):
		return '<Role %r>' % self.name

class User(db.Model):
	__tablename__ = 'users'
	id = db.Column(db.Integer, primary_key = True)
	username = db.Column(db.String(64), unique = True, index = True)
	role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))

	def __repr__(self):
		return '<User %r>' % self.username 

App主腳本 (db_demo.py)

import os
from app import create_app, db
from app.models import User, Role
from flask_migrate import Migrate

#建立一個App,從環境變數或預設來取得組態設置。
app = create_app(os.getenv('FLASK_CONFIG') or 'default')

##初始化Flask-Migrate
migrate = Migrate(app, db)

##Python殼層的自訂context
@app.shell_context_processor
def make_shell_context():
    return dict(db=db, User=User, Role=Role)
    
### 單元測試 ###

requirements檔

(venv) $ pip freeze > requirements.txt
  • 紀錄所有的套件依賴項目,以及確切的版本號。
  • 透過Windows cmd,自動產生。

LAB - 3

(venv) $ deactivate
$ cd ..
$ mkdir test
$ cd test
$ virtualenv venv_test
$ venv_test\Scripts\activate
(venv_test) $ pip install C:\Users\pcsh1\OneDrive\桌面\mysqlclient-1.4.2-cp37-cp37m-win32.whl
(venv_test) $ pip install -r requirements.txt
(venv_test) $ pip freeze

複製完全一樣的虛擬環境

單元測試

C:\Users\pcsh1\flask_beginner_1.2.3.4
├─tests
   │  test_basics.py
   │  __init__.py      --- 讓測試目錄成為有效的套件 (空檔案)
  • __init__.py 可以為空檔案,因為unittest套件會掃描所有的模組來尋找測試程式。

單元測試 (tests/test_basics.py)

import unittest
from flask import current_app
from app import create_app, db
  • 使用Python標準程式庫的unittest套件
  • 目標:定義兩個簡單的測試 (test_app_exists、test_app_is_testing)
class BasicsTestCase(unittest.TestCase):
    #在每次測試之前執行,幫測試程式建立一個類似運行中App的環境
    def setUp(self):
        #建立一個testing組態的App,並啟動context
        self.app = create_app('testing')  #動態套用testing組態設定
        self.app_context = self.app.app_context()  #維持目前(套用testingDB)的狀態
        self.app_context.push() #push() is used to add items to the stack. 
        db.create_all() #為測試程式建立全新的資料庫
    
    #在每次測試之後執行
    def tearDown(self):
        db.session.remove()
        db.drop_all()
        self.app_context.pop()

    #確認App實例的存在
    def test_app_exists(self):
        self.assertFalse(current_app is None)
    
    #確認App在測試組態下運行
    def test_app_is_testing(self):
        self.assertTrue(current_app.config['TESTING'])

單元測試啟動命令 (db_demo.py)

# ...

#自訂命令
@app.cli.command()
def test1():
    """Run the unit tests."""
    import unittest
    tests = unittest.TestLoader().discover('tests')  #會自動去找【tests資料夾】
    unittest.TextTestRunner(verbosity=2).run(tests)

    #verbosity=1 輸出結果之詳細程度(簡略) vs. verbosity=2 (詳細)
  • 透過Windows cmd,執行test1測試

(venv) $ flask test1

用Flask-Migrate進行資料庫遷移

C:\Users\pcsh1\flask_beginner_1.2.3.4
├─migrations
   │  versions
   │  ...
  • 有時候我們會改動Model,例如新增或刪除某個欄位,這時候就要用Migrate來進行更新
(venv) $ pip install Flask-Migrate
  • 安裝套件

  • 初始化(db_demo.py)

from flask_migrate import Migrate

##初始化Flask-Migrate
migrate = Migrate(app, db)
  • 執行指令

(venv) $ flask db init
testcol = db.Column(db.Integer) #新增至models.py
  • 建立遷移腳本

  • 執行遷移腳本

(venv) $ flask db migrate
(venv) $ flask db upgrade

先修改完model.py之後,使用以下指令。

  • 檢查是否更新到資料庫

show databases;
use flaskdb;
show tables;
SELECT * FROM users; 

執行App

(venv) $ set FLASK_APP=db_demo.py
(venv) $ set FLASK_DEBUG=1

(venv) $ set MAIL_USERNAME=pcsh110576@gmail.com
(venv) $ set MAIL_PASSWORD=**************
(venv) $ set FLASKY_ADMIN=bessy110576@gmail.com

(venv) $ flask run
  • 透過Windows cmd,執行flask run

Thanks for your listening

Flask - Email

By BessyHuang

Flask - Email

  • 363