來說明這本由Miguel Grinberg著作,賴屹民翻譯的《Flask網頁開發 第二版》(Flask Web Development, 2nd Edition),本書的撰寫方式如同網頁開發人員新增應用程式功能,每新增一項功能,就會有新的章節說明這樣的新功能,是使用什麼樣的技術製作而成;由於網頁應用程式具結構性,程式碼之間的撰寫與修改是息息相關的,故作者有提供Repository供讀者檢視與試驗。
我們可以依照本書各章節的順序,逐一檢視各章節在Repository對應的 Branch,藉由各個Branch的切換與推移,可以學習、理解與推演作者修改程式的邏輯,這對於Flask的學習是非常重要的。
「Flask網頁開發」系列網誌,整理的方向也只是羅列修改方向一致的各個Branch,統整為較容易查找的功能主題,並輔以註解說明特殊模組、套件的API如何使用,以及使用的目的,對於完全沒有網頁應用程式開發經驗的讀者來說,依舊是極度難以理解的一系列筆記,建議還是得先學習好Flask的基礎,再掌握好本書作者訂定的目錄框架,最後才能掌握住本書的程式開發方式。
第二部分、範例:社群部落格app
第九章、使用者角色-使用者角色和權限(9a)
l 使用者角色和權限:
git checkout 9a
l app/models.py:
# ...
from flask import current_app
from flask_login import UserMixin, AnonymousUserMixin
from . import db, login_manager
# 使用二的次方做為權限值可讓我們結合各種權限,讓每一種權限組合都有不重複的值
class Permission:
FOLLOW = 1
COMMENT = 2
WRITE = 4
MODERATE = 8
ADMIN = 16
class Role(db.Model):
__tablename__ = "roles"
id = db.Column(db.Integer,
primary_key=True)
name = db.Column(db.String(64),
unique=True)
default = db.Column(db.Boolean,
default=False, index=True)
permissions = db.Column(db.Integer)
users =
db.relationship("User", backref="role",
lazy="dynamic")
def __init__(self, **kwargs):
super(Role, self).__init__(**kwargs)
if self.permissions is None:
self.permissions = 0
# insert_roles()是個靜態方法,不需要建立物件,可以直接Role.insert_roles()呼叫它,也不像實例方法可以接收self引數,用途是在資料庫裡面還沒有該角色時建立新的角色物件
@staticmethod
def insert_roles():
roles = {
"User":
[Permission.FOLLOW, Permission.COMMENT, Permission.WRITE],
"Moderator":
[Permission.FOLLOW, Permission.COMMENT, Permission.WRITE, Permission.MODERATE],
"Administrator":
[Permission.FOLLOW, Permission.COMMENT, Permission.WRITE, Permission.MODERATE,
Permission.ADMIN],
}
default_role = "User"
for r in roles:
role =
Role.query.filter_by(name=r).first()
if role is None:
role = Role(name=r)
role.reset_permissions()
for perm in roles[r]:
role.add_permission(perm)
role.default = (role.name ==
default_role)
db.session.add(role)
db.session.commit()
# 於flask shell展示這些涵式的使用範例
# r = Role(name="User")
# r.add_permission(Permission.FOLLOW)
def add_permission(self, perm):
if not self.has_permission(perm):
self.permissions += perm
def remove_permission(self, perm):
if self.has_permission(perm):
self.permissions -= perm
def reset_permissions(self):
self.permissions = 0
# has_permission()使用位元運算子&來檢查結合起來的權限值有沒有包含它收到的基本權限
def has_permission(self, perm):
return self.permissions &
perm == perm
# ...
class User(UserMixin, db.Model):
__tablename__ = "users"
id = db.Column(db.Integer,
primary_key=True)
email = db.Column(db.String(64),
unique=True, index=True)
username = db.Column(db.String(64),
unique=True, index=True)
role_id = db.Column(db.Integer,
db.ForeignKey("roles.id"))
password_hash =
db.Column(db.String(128))
confirmed = db.Column(db.Boolean,
default=False)
def __init__(self, **kwargs):
super(User,
self).__init__(**kwargs)
if self.role is None:
if self.email ==
current_app.config["FLASKY_ADMIN"]:
self.role =
Role.query.filter_by(name="Administrator").first()
if self.role is None:
self.role =
Role.query.filter_by(default=True).first()
# ...
def can(self, perm):
return self.role is not None and
self.role.has_permission(perm)
def is_administrator(self):
return self.can(Permission.ADMIN)
# ...
# AnonymousUser類別,可讓app自由地呼叫current_user.can()與current_user.is_administrator(),而不需要先檢查使用者究竟有沒有登入
class AnonymousUser(AnonymousUserMixin):
def can(self, permissions):
return False
def is_administrator(self):
return False
login_manager.anonymous_user = AnonymousUser
l 以flask shell手動更新新的角色物件:
# 將新角色加入開發資料庫
Role.insert_roles()
Role.query.all() à [<Role 'User'>, <Role 'Moderator'>, <Role
'Administrator'>]
# 也要更新使用者清單
admin_role = Role.query.filter_by(name="Administrator").first()
default_role = Role.query.filter_by(default=True).first()
for u in User.query.all():
if u.role is None:
if u.email ==
app.config["FLASKY_ADMIN"]:
u.role = admin_role
else:
u.role = default_role
db.session.commit()
l app/decorators.py:
from functools import wraps
from flask import abort
from flask_login import current_user
from .models import Permission
# 檢查使用者權限的自訂裝飾器,在目前的使用者沒有要求的權限時,回傳403回應,即拒絕HTTP狀態碼
def permission_required(permission):
def decorator(f):
@wraps(f)
def decorated_function(*args,
**kwargs):
if not
current_user.can(permission):
abort(403)
return f(*args, **kwargs)
return decorated_function
return decorator
def admin_required(f):
return
permission_required(Permission.ADMIN)(f)
l 本書作者自訂裝飾器的用法:
from .decorators import admin_required, permission_required
@main.route("/admin")
@login_required
@admin_required
def for_admins_only():
return "For
administrators!"
@main.route("/moderate")
@login_required
@permission_required(Permission.MODERATE)
def for_moderators_only():
return "For comment
moderators!"
l app/main/__init__.py:
# ...
from ..models import Permission
# 模板必須能夠讀取Permission類別及其所有常數,為了避免每次呼叫render_template()時都要提供模板引數,我們可以使用context處理常式,在模板context加入Permission類別
@main.app_context_processor
def inject_permissions():
return dict(Permission=Permission)
l tests/test_user_model.py:
import unittest
from app import create_app, db
from app.models import User, AnonymousUser, Role, Permission
# ...
class UserModelTestCase(unittest.TestCase):
def setUp(self):
self.app =
create_app("testing")
self.app_context =
self.app.app_context()
self.app_context.push()
db.create_all()
Role.insert_roles()
def tearDown(self):
db.session.remove()
db.drop_all()
self.app_context.pop()
# ...
def test_user_role(self):
u =
User(email="john@example.com", password="cat")
self.assertTrue(u.can(Permission.FOLLOW))
self.assertTrue(u.can(Permission.COMMENT))
self.assertTrue(u.can(Permission.WRITE))
self.assertFalse(u.can(Permission.MODERATE))
self.assertFalse(u.can(Permission.ADMIN))
def test_moderator_role(self):
r =
Role.query.filter_by(name="Moderator").first()
u =
User(email="john@example.com", password="cat", role=r)
self.assertTrue(u.can(Permission.FOLLOW))
self.assertTrue(u.can(Permission.COMMENT))
self.assertTrue(u.can(Permission.WRITE))
self.assertTrue(u.can(Permission.MODERATE))
self.assertFalse(u.can(Permission.ADMIN))
def test_administrator_role(self):
r =
Role.query.filter_by(name="Administrator").first()
u =
User(email="john@example.com", password="cat", role=r)
self.assertTrue(u.can(Permission.FOLLOW))
self.assertTrue(u.can(Permission.COMMENT))
self.assertTrue(u.can(Permission.WRITE))
self.assertTrue(u.can(Permission.MODERATE))
self.assertTrue(u.can(Permission.ADMIN))
def test_anonymous_user(self):
u = AnonymousUser()
self.assertFalse(u.can(Permission.FOLLOW))
self.assertFalse(u.can(Permission.COMMENT))
self.assertFalse(u.can(Permission.WRITE))
self.assertFalse(u.can(Permission.MODERATE))
self.assertFalse(u.can(Permission.ADMIN))
第十章、使用者個人資訊-使用者資料和編輯(10a、10b)
l 使用者資料和編輯:
git checkout 10a
git checkout 10b
l app/models.py:
from datetime import datetime
from flask_login import UserMixin, AnonymousUserMixin
# ...
class User(UserMixin, db.Model):
# ...
name = db.Column(db.String(64))
location = db.Column(db.String(64))
# db.Text是可變長度欄位
about_me = db.Column(db.Text())
# default引數可以接收函式
member_since =
db.Column(db.DateTime(), default=datetime.utcnow)
last_seen = db.Column(db.DateTime(),
default=datetime.utcnow)
# 更新使用者上次造訪的時間
def ping(self):
self.last_seen =
datetime.utcnow()
db.session.add(self)
db.session.commit()
l app/auth/views.py:
# ping登入的使用者
@auth.before_app_request
def before_request():
if current_user.is_authenticated:
current_user.ping()
if not current_user.confirmed and
request.endpoint and request.blueprint != "auth" and request.endpoint
!= "static":
return
redirect(url_for("auth.unconfirmed"))
l app/main/forms.py:
# 使用者階級的個人資訊編輯網頁
class EditProfileForm(FlaskForm):
name = StringField("Real
name", validators=[Length(0, 64)])
location =
StringField("Location", validators=[Length(0, 64)])
about_me = TextAreaField("About
me")
submit =
SubmitField("Submit")
# 管理員階級的個人資訊編輯網頁
class EditProfileAdminForm(FlaskForm):
email =
StringField("Email", validators=[DataRequired(), Length(1, 64),
Email()])
username =
StringField("Username", validators=[DataRequired(), Length(1, 64),
Regexp("^[A-Za-z][A-Za-z0-9_.]*$", 0, "Usernames must have only
letters, numbers, dots or underscores")])
confirmed =
BooleanField("Confirmed")
# 用coerce=int引數來以整數儲存欄位值,而不是預設的字串值
role = SelectField("Role",
coerce=int)
name = StringField("Real
name", validators=[Length(0, 64)])
location =
StringField("Location", validators=[Length(0, 64)])
about_me = TextAreaField("About
me")
submit =
SubmitField("Submit")
def __init__(self, user, *args, **kwargs):
super(EditProfileAdminForm,
self).__init__(*args, **kwargs)
# SelectField實例實作了下拉式清單,用choices屬性來設定項目
self.role.choices = [(role.id,
role.name) for role in Role.query.order_by(Role.name).all()]
self.user = user
def validate_email(self, field):
if field.data != self.user.email
and User.query.filter_by(email=field.data).first():
raise
ValidationError("Email already registered.")
def validate_username(self, field):
if field.data !=
self.user.username and User.query.filter_by(username=field.data).first():
raise
ValidationError("Username already in use.")
l app/main/views.py:
# 個人資訊網頁路由
@main.route("/user/<username>")
def user(username):
# first_or_404()用單一陳述式完美地結合搜尋與錯誤案例
user =
User.query.filter_by(username=username).first_or_404()
return
render_template("user.html", user=user)
# 使用者階級的個人資訊編輯網頁
@main.route("/edit-profile", methods=["GET",
"POST"])
@login_required
def edit_profile():
form = EditProfileForm()
if form.validate_on_submit():
current_user.name =
form.name.data
current_user.location =
form.location.data
current_user.about_me =
form.about_me.data
db.session.add(current_user._get_current_object())
db.session.commit()
flash("Your profile has been
updated.")
return
redirect(url_for(".user", username=current_user.username))
form.name.data = current_user.name
form.location.data =
current_user.location
form.about_me.data = current_user.about_me
return
render_template("edit_profile.html", form=form)
# 管理員階級的個人資訊編輯網頁
@main.route("/edit-profile/<int:id>", methods=["GET",
"POST"])
@login_required
@admin_required
def edit_profile_admin(id):
user = User.query.get_or_404(id)
form =
EditProfileAdminForm(user=user)
if form.validate_on_submit():
user.email = form.email.data
user.username =
form.username.data
user.confirmed =
form.confirmed.data
user.role =
Role.query.get(form.role.data)
user.name = form.name.data
user.location =
form.location.data
user.about_me =
form.about_me.data
db.session.add(user)
db.session.commit()
flash("The profile has been
updated.")
return
redirect(url_for(".user", username=user.username))
form.email.data = user.email
form.username.data = user.username
form.confirmed.data = user.confirmed
form.role.data = user.role_id
form.name.data = user.name
form.location.data = user.location
form.about_me.data = user.about_me
return
render_template("edit_profile.html", form=form, user=user)
l templates/base.html:
<!-- ... -->
<ul class="nav navbar-nav">
<li><a href="{{
url_for('main.index') }}">Home</a></li>
{% if current_user.is_authenticated %}
<li><a href="{{
url_for('main.user', username=current_user.username)
}}">Profile</a></li>
{% endif %}
</ul>
<!-- ... -->
l templates/edit_profile.html:
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block title %}Flasky - Edit Profile{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>Edit Your
Profile</h1>
</div>
<div class="col-md-4">
{{ wtf.quick_form(form) }}
</div>
{% endblock %}
l templates/user.html:
{% extends "base.html" %}
{% block title %}Flasky - {{ user.username }}{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>{{ user.username
}}</h1>
{% if user.name or user.location %}
<p>
{% if user.name %}{{ user.name
}}{% endif %}
{% if user.location %}
from <a
href="http://maps.google.com/?q={{ user.location }}">{{
user.location }}</a>
{% endif %}
</p>
{% endif %}
{% if current_user.is_administrator()
%}
<p><a href="mailto:{{
user.email }}">{{ user.email }}</a></p>
{% endif %}
{% if user.about_me %}<p>{{
user.about_me }}</p>{% endif %}
<p>Member since {{
moment(user.member_since).format('L') }}. Last seen {{
moment(user.last_seen).fromNow() }}.</p>
<p>
{% if user == current_user %}
<a class="btn
btn-default" href="{{ url_for('.edit_profile') }}">Edit
Profile</a>
{% endif %}
{% if
current_user.is_administrator() %}
<a class="btn
btn-danger" href="{{ url_for('.edit_profile_admin', id=user.id)
}}">Edit Profile [Admin]</a>
{% endif %}
</p>
</div>
{% endblock %}
l tests/test_user_model.py:
# ...
class UserModelTestCase(unittest.TestCase):
# ...
def test_timestamps(self):
u =
User(password="cat")
db.session.add(u)
db.session.commit()
self.assertTrue((datetime.utcnow() - u.member_since).total_seconds()
< 3)
self.assertTrue((datetime.utcnow() - u.last_seen).total_seconds() <
3)
def test_ping(self):
u =
User(password="cat")
db.session.add(u)
db.session.commit()
time.sleep(2)
last_seen_before = u.last_seen
u.ping()
self.assertTrue(u.last_seen >
last_seen_before)
第十章、使用者個人資訊-使用者頭像和快取(10c、10d)
l 使用者頭像和快取:
git checkout 10c
git checkout 10d
l 加入頭像服務Gravatar提供的使用者頭像,使用者要在https://gravatar.com建立帳號,再上傳他們的圖像。
l 這個服務會透過特製的URL來公開使用者的頭像,URL包括使用者email地址的MD5雜湊,這個雜湊可以這樣計算:
import hashlib
hashlib.md5("john@example.com".encode("utf-8")).hexdigest()
接著將MD5雜湊接到https://secure.gravatar.com/avatar/後面即可產生頭像URL。
l Gravatar查詢字串引數範例與說明:
https://secure.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=180&d=identicon&r=g
s |
圖像大小,以像素為單位。 |
r |
圖像分級。選項有"g"、"pg"、"r"與"x"。 |
d |
當使用者沒有在Gravatar服務註冊頭像時使用的預設圖像產生器。選項包括回傳404錯誤的"404"、指向預設圖像的URL,或下面的圖像產生器之一:"mm"、"identicon"、"monsterid"、"wavatar"、"retro"或"blank"。 |
fd |
強制使用預設的頭像。 |
l app/models.py:
# ...
import hashlib
# ...
# 生成gravatar URL並緩存MD5雜湊
class User(UserMixin, db.Model):
# ...
avatar_hash = db.Column(db.String(32))
def __init__(self, **kwargs):
# ...
if self.email is not None and
self.avatar_hash is None:
self.avatar_hash =
self.gravatar_hash()
def change_email(self, token):
# ...
self.email = new_email
self.avatar_hash =
self.gravatar_hash()
db.session.add(self)
return True
def gravatar_hash(self):
return
hashlib.md5(self.email.lower().encode("utf-8")).hexdigest()
def gravatar(self, size=100,
default="identicon", rating="g"):
url = "https://secure.gravatar.com/avatar"
hash = self.avatar_hash or
self.gravatar_hash()
return
"{url}/{hash}?s={size}&d={default}&r={rating}".format(url=url,
hash=hash, size=size, default=default, rating=rating)
l app/templates/base.html:
<!-- ... -->
{% block head %}
<!-- ... -->
<!-- 載入控制頭像樣式的CSS -->
<link rel="stylesheet" type="text/css" href="{{
url_for('static', filename='styles.css') }}">
{% endblock %}
<!-- ... -->
<li
class="dropdown">
<a
href="#" class="dropdown-toggle"
data-toggle="dropdown">
<!-- 使用者帳號旁邊的頭像
-->
<img
src="{{ current_user.gravatar(size=18) }}">
Account <b
class="caret"></b>
</a>
<!-- ... -->
</li>
l app/templates/user.html:
<!-- ... -->
<!-- 作者自訂的profile-thumbnail CSS類別可協助在網頁指定圖像的位置 -->
<img class="img-rounded profile-thumbnail" src="{{
user.gravatar(size=256) }}">
<!-- ... -->
l app/static/styles.css:
.profile-thumbnail {
position: absolute;
}
.profile-header {
min-height: 260px;
margin-left: 280px;
}
l tests/test_user_model.py:
# ...
class UserModelTestCase(unittest.TestCase):
# ...
def test_gravatar(self):
u =
User(email="john@example.com", password="cat")
# 與app.test_request_context()一起使用來發送request內容;採用與測試client請求方法相同的參數
with
self.app.test_request_context("/"):
gravatar = u.gravatar()
gravatar_256 =
u.gravatar(size=256)
gravatar_pg =
u.gravatar(rating="pg")
gravatar_retro =
u.gravatar(default="retro")
self.assertTrue("https://secure.gravatar.com/avatar/" +
"d4c74594d841139328695756648b6bd6" in gravatar)
self.assertTrue("s=256"
in gravatar_256)
self.assertTrue("r=pg"
in gravatar_pg)
self.assertTrue("d=retro" in gravatar_retro)
沒有留言:
張貼留言