2024年3月31日 星期日

Flask網頁開發 - 第二版 - 使用者角色與個人資訊 (4)

  來說明這本由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))

第十章、使用者個人資訊-使用者資料和編輯(10a10b)

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)

第十章、使用者個人資訊-使用者頭像和快取(10c10d)

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)

沒有留言:

張貼留言