The Flask Mega-Tutorial 之 Chapter 6: Profile Page and Avatars

Objective

  • Add user profile pages to the application (generate profile pages for all users dynamically).
  • Add a small profile editor for users to enter information.

User Profile Page

  • app / routes.py: 創建 User profile 的 view function
@app.route('/user/<username>')
@login_required
def user(username):
    user = User.query.filter_by(username=username).first_or_404()
    posts = [
        {'author': user, 'body': 'Test post #1'},
        {'author': user, 'body': 'Test post #2'}
    ]
    return render_template('user.html', user=user, posts=posts)

(1) < > 用來標明 dynamic component of URL.
(2) 只對 logged 用戶可見,故 @login_required
(3) first_or_404() ,如無結果,則返回 404 error

  • app / templates / user.html: User profile 模板
{% extends "base.html" %}

{% block content %}
    <h1>User: {{ user.username }}</h1>
    <hr>
    {% for post in posts %}
    <p>
    {{ post.author.username }} says: <b>{{ post.body }}</b>
    </p>
    {% endfor %}
{% endblock %}

創建鏈接到 User Profile 的入口,如下:

  • app / templates / base.html: 在 base.html 中創建 User profile 的鏈接入口
<body>
    <div>
      Microblog:
      <a href="{{ url_for('index') }}">Home</a>
      {% if current_user.is_anonymous %}
      <a href="{{ url_for('login') }}">Login</a>
      {% else %}
      <a href="{{ url_for('user', username=current_user.username) }}">Profile</a>
      <a href="{{ url_for('logout') }}">Logout</a>
      {% endif %}
    </div>
</body>

(1) Profile 入口只對 logged 用戶可見。
(2) 通過 url_for(‘user’, username=current_user.username) 定位至 ‘user’ (view func),並傳入dynamic component/user/<username> 中的 username.

如果自己已登錄,則點擊頂部的Profile,會跳轉出現如下類似的頁面;
尚無鏈接可至其他 user’s profile, 但可手動在 地址欄輸入,如http://localhost:5000/user/john (前提是 john 已註冊,可從db 提取到)。

這裏寫圖片描述


Avatars

  • 爲使 User Profile 配頭像,採用 Gravatar 服務(服務器無需管理上傳的圖片)。
  • Gravatar URL 格式 https://www.gravatar.com/avatar/<hash> ,其中 <hash> 是 MD5 hash of user’s email。
  • Gravatar URL 生成方式如下:
>>> from hashlib import md5
>>> 'https://www.gravatar.com/avatar/' + md5(b'[email protected]').hexdigest()
'https://www.gravatar.com/avatar/d4c74594d841139328695756648b6bd6'
  • 配置 https://www.gravatar.com/avatar/<hash>?d=identicon&s=<size>'

d = identicon: generate an “identicon” image for users that have not registered avatar.
s = <size> : default 80x80 pixels, we can set 128 for profile with a 128x128 pixels avatar.

some web browser extensions such as Ghostery block Gravatar images.


1、app / models.py: 爲 User Model 添加 avatar URLs

from hashlib import md5
# ...

class User(UserMixin, db.Model):
    # ...
    def avatar(self, size):
        digest = md5(self.email.lower().encode('utf-8')).hexdigest()
        return 'https://www.gravatar.com/avatar/{}?d=identicon&s={}'.format(
            digest, size)

(1) Gravatar 要求 email 是小寫,故先統一轉爲小寫。
(2) Python 中的 MD5 支持 bytes操作(不支持 string),故在傳入解析前先進行編碼(‘utf-8’)。

: 在 User Model 中定義 avatar() 的好處是,一旦之後想改變(如不用 Gravatar avatars),可以重新改寫 avatar() 以返回不同的 URLs。


2、app / templates / user.html: 將 avatar 添加至 user profile 模板

{% extends "base.html" %}

{% block content %}
    <table>
        <tr valign="top">
            <td><img src="{{ user.avatar(128) }}"></td>
            <td><h1>User: {{ user.username }}</h1></td>
        </tr>
    </table>
    <hr>
    {% for post in posts %}
    <p>
    {{ post.author.username }} says: <b>{{ post.body }}</b>
    </p>
    {% endfor %}
{% endblock %}

(1) 加了一個<table> 標籤,其中 <tr> 爲 table row, <td> 爲 table data; <tr> 中的 valign= “top”,指垂直靠上。
(2) 插入一個table,包含一行,行中包括兩個元素:

  • 第一個元素爲 avatar,調用 User Model 中定義的 avatar(),並傳入 size 參數128;
  • 第二個元素爲 用戶名(此user.username 已經過路由限定 爲 logged)。

3、app / templates / user.html: 將縮小版的 avatar 添加到 Profile 中下方出現的 posts 中

{% extends "base.html" %}

{% block content %}
    <table>
        <tr valign="top">
            <td><img src="{{ user.avatar(128) }}"></td>
            <td><h1>User: {{ user.username }}</h1></td>
        </tr>
    </table>
    <hr>
    {% for post in posts %}
    <table>
        <tr valign="top">
            <td><img src="{{ post.author.avatar(36) }}"></td>
            <td>{{ post.author.username }} says:<br>{{ post.body }}</td>
        </tr>
    </table>
    {% endfor %}
{% endblock %}

(1) 插入第2個 <table> 標籤,插入位置爲 {% for post in posts %} <table></table> {% endfor %} 之間。
(2) 依然一個<tr valign="top"></tr> 標籤,包括兩個元素:

  • 第一個元素:post.author.avatar(36) 對應每個 post 的小頭像。
  • 第二個元素:原來<p></P> 中的內容,{{ post.author.username }} says: <br> {{ post.body }},注意加重標籤<b></b> 改爲了換行標籤 <br>

    這裏寫圖片描述


Using Jinja2 Sub-Templates

緣起:User Profile 下方展示了對應 user 發表的 posts,若別的頁面模板也有類似的 posts 展示需求,則雖然可以通過簡單的copy/paste 完成,但如果之後發生變動,則需要修改多處模板。

1、 app / templates / _post.html: 創建 展示 posts 的sub_template,即 _post.html

    <table>
        <tr valign="top">
            <td><img src="{{ post.author.avatar(36) }}"></td>
            <td>{{ post.author.username }} says:<br>{{ post.body }}</td>
        </tr>
    </table>

即將原 user.html 中的 posts 部分單獨創建爲 _post.html,然後供別的需求模板調用 (include)。

2、app / templates / user.html: 在原 user profile 中 引入 _post.html

{% extends "base.html" %}

{% block content %}
    <table>
        <tr valign="top">
            <td><img src="{{ user.avatar(128) }}"></td>
            <td><h1>User: {{ user.username }}</h1></td>
        </tr>
    </table>
    <hr>
    {% for post in posts %}
        {% include '_post.html' %}
    {% endfor %}
{% endblock %}

注意引用語法: {% include "_post.html" %}


More Interesting Profiles

  • 可自我編輯 (about_me)
  • 可追蹤最近登錄時間 (last_seen)

1、這類顯示信息,需要與 db 發生交互,且屬於 User 的屬性,所以首先更新 User Model:

app / models.py: 將這兩類屬性添加至 user model

class User(UserMixin, db.Model):
    # ...
    about_me = db.Column(db.String(140))
    last_seen = db.Column(db.DateTime, default=datetime.utcnow)
  • 限定 about_me 的大小爲 140 字符(非字節)
  • last_seen,默認 datetime.utcnow,與時區無關(暫時不管具體時區的轉換及datetime格式)

2、 更新了 User Model,則須 flask db migrate & flask db upgrade

(venv) $ flask db migrate -m "new fields in user model"
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added column 'user.about_me'
INFO  [alembic.autogenerate.compare] Detected added column 'user.last_seen'
  Generating /home/miguel/microblog/migrations/versions/37f06a334dbf_new_fields_in_user_model.py ... done
(venv) $ flask db upgrade
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade 780739b227a7 -> 37f06a334dbf, new fields in user model

注: migration framework 非常有用(useful)。 db 中原有的數據依然存在,只是通過 flask db upgradeflask db migrate 產生的 migration script 在不損壞任何數據的情況下更改 db 的結構。

I hope you realize how useful it is to work with a migration framework. Any users that were in the database are still there, the migration framework surgically applies the changes in the migration script without destroying any data.


3、將這兩個新字段,添加到 User Profile (user.html )中

app / templates / user.html: 將 about_me & last_seen 添加到 user profile 模板

{% extends "base.html" %}

{% block content %}
    <table>
        <tr valign="top">
            <td><img src="{{ user.avatar(128) }}"></td>
            <td>
                <h1>User: {{ user.username }}</h1>
                {% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
                {% if user.last_seen %}<p>Last seen on: {{ user.last_seen }}</p>{% endif %}
            </td>
        </tr>
    </table>
    <hr>
    {% for post in posts %}
        {% include '_post.html' %}
    {% endfor %}
    ...
{% endblock %}

注: wrapping these two fields in Jinja2’s conditionals, because we want them to be visible if they are set.


Recording The Last Visit Time For a User

目標: 對於一個給定的 user,在其向 server 發起請求時,無論何時/無論何種路由,都希望能將current time 寫入到此 user 的 last_seen 字段中。

@app.before_request: 可使被裝飾的函數,在執行任何路由函數之前先執行。

app / routes.py: record time of last visit

from datetime import datetime

@app.before_request
def before_request():
    if current_user.is_authenticated:
        current_user.last_seen = datetime.utcnow()
        db.session.commit()

無需 db.session.add(user)。
判斷 current_user 時,已經通過 Flask-Login 的 user loader (models.py 中)將 db 中的目標 user 載入 db session,所以後面在更新此 user 的 last_seen 後,才用db.session.commit() 提交。

If you are wondering why there is no db.session.add() before the commit, consider that when you reference current_user, Flask-Login will invoke the user loader callback function, which will run a database query that will put the target user in the database session. So you can add the user again in this function, but it is not necessary because it is already there.

這裏寫圖片描述

  • Storing timestamps in the UTC timezone makes the time displayed on the profile page also be in UTC.
  • Further, time format is not ideal, since it is the internal representation of Python datetime object.

這兩個問題,稍後的章節會處理。


Profile Editor

1、創建 EditProfileForm

app / forms.py: 創建 profile editor form

from wtforms import StringField, TextAreaField, SubmitField
from wtforms.validators import DataRequired, Length

# ...

class EditProfileForm(FlaskForm):
    username = StringField('Username', validators=[DataRequired()])
    about_me = TextAreaField('About me', validators=[Length(min=0, max=140)])
    submit = SubmitField('Submit')
  • wtforms引入 TextAreaField,自 wtforms.validators 引入 Length
  • about_me 非必須,所以未設置 DataRequired()
  • Length 的限制爲 140 字符,和 User Model 中設置的字段大小保持一致 (about_me = db.Column(db.String(140))

2、創建 自我編輯 的模板

app / templates / edit_profile.html: profile editor form

{% extends "base.html" %}

{% block content %}
    <h1>Edit Profile</h1>
    <form action="" method="post">
        {{ form.hidden_tag() }}
        <p>
            {{ form.username.label }}<br>
            {{ form.username(size=32) }}<br>
            {% for error in form.username.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>
            {{ form.about_me.label }}<br>
            {{ form.about_me(cols=50, rows=4) }}<br>
            {% for error in form.about_me.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>{{ form.submit() }}</p>
    </form>
{% endblock %}


3、創建 自我編輯 的路由

app / routes.py: 自我編輯的路由 (view func for edit profile)

from app.forms import EditProfileForm

@app.route('/edit_profile', methods=['GET', 'POST'])
@login_required
def edit_profile():
    form = EditProfileForm()
    if form.validate_on_submit():
        current_user.username = form.username.data
        current_user.about_me = form.about_me.data
        db.session.commit()
        flash('Your changes have been saved.')
        return redirect(url_for('edit_profile'))
    elif request.method == 'GET':
        form.username.data = current_user.username
        form.about_me.data = current_user.about_me
    return render_template('edit_profile.html', title='Edit Profile',
                           form=form)

注:設置了 @login_required(登錄方可自我編輯)

(1) form.validate_on_submit() 判斷爲 ‘True’ 時,依據成功提交的 form 中的信息改寫 current_userusernameabout_me 字段值並 commit 到 db,然後 flash 提示信息,最終重定向至‘edit_profile’。

(2) form.validate_on_submit() 判斷爲 ‘False’ 時,有兩種情況:

  • 第一種情況:瀏覽器發送的是初次 ‘GET‘ 請求(通過 request.method 判斷),我們應在返回樣表(edit_profile.html)的基礎上,先從 db中 調用 current_user 中的字段數據,並賦值 form.username.dataform.about_me.data, 最終返回 pre-populatededit_profile.html.
  • 第二種情況:瀏覽器發送的是失敗的 ‘POST‘ 請求(a submission that failed validation),返回空白edit_profile.html

這裏寫圖片描述

4、爲使 user 便於看到 Edit Profile 入口,則在 Profile 頁面添加鏈接入口:

app / templates / user.html: 添加 edit profile 鏈接入口

{% extends "base.html" %}

{% block content %}
    <table>
        <tr valign="top">
            <td><img src="{{ user.avatar(128) }}"></td>
            <td>
                <h1>User: {{ user.username }}</h1>
                {% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
                {% if user.last_seen %}<p>Last seen on: {{ user.last_seen }}</p>{% endif %}
                {% if user == current_user %}
                <p><a href="{{ url_for('edit_profile') }}">Edit your profile</a></p>
                {% endif %}
            </td>
        </tr>
    </table>
    <hr>
    {% for post in posts %}
        {% include '_post.html' %}
    {% endfor %}
{% endblock %}

加入第3條判斷語句: 保證只有在瀏覽自己的 profile 時,纔會出現 Edit link ; 瀏覽其他人的 profile 時, 鏈接入口不可見。

這裏寫圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章