第10章:用户资料-Flask Web 开发(第2版)

本章将完成 Flasky 项目的用户资料部分。所有具有社交性质的网站都会为用户提供个人资料页面,其中显示了用户在该网站的摘要信息。用户可以将他们在该网站的个人资料页面的 URL 分享给其他人,以便对自己进行宣传,所以 URL 简短易记是非常重要的。

资料信息

为了让用户的个人资料页面更有趣,可以将一些关于用户的附加信息存储在数据库中。示例10-1显示 User 模型中新加一些字段。

# 示例 10-1. app/models.py: 用户信息字段
class User(UserMixin, db.Model):
    # ...
    name = db.Column(db.String(64))
    location = db.Column(db.String(64))
    about_me = db.Column(db.Text())
    member_since = db.Column(db.DateTime(), default=datetime.utcnow)
    last_seen = db.Column(db.DateTime(), default=datetime.utcnow)

这些新加的字段记录了用户的真实姓名、位置、自我介绍、注册时间和最后访问时间。自我介绍(about_me)字段使用的是 db.Text,它和 db.String 的区别在于,理论上 db.Text 没有长度限制。两个日期时间字段的默认值设置成了当前的时间。注意,datetime.utcnow 没有括号,这是因为 default 会去调用指定的函数,从而在用户注册或登录的时候动态地获取当前时间,而不是直接把函数的返回值给 default

最后访问时间(last_seen)需要在用户每次访问站点时,自动更新为当前时间(默认值设置的是用户注册时的时间)。所以,需要在 User 模型时增加一个助手函数。

# 示例 10-2. app/models.py: 更新用户最后访问时间
class User(UserMixin, db.Model):
    # ...

    def ping(self):
        self.last_seen = datetime.utcnow()
        db.session.add(self)
        db.session.commit()

要保证每个用户每个请求的最后访问时间都能正确更新,需要在 before_app_request 里调用 User 模型的 ping() 方法。

# 示例 10-3. app/auth/views.py: 更新最后访问时间
@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'))

用户资料页面

开发用户资料页面不需要任何新的技术,如示例10-4所示。

# 示例 10-4. app/main/views.py: 用户资料路由
@main.route('/user/<username>')
def user(username):
    user = User.query.filter_by(username=username).first_or_404()
    return render_template('user.html', user=user)

假如用户名是 john,那么他的用户资料页面的 URL 是:http://localhost:5000/user/john

该路由对应的模板文件如下:

<!-- 示例 10-5. app/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 %}
            位置 <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>
        注册时间 {{ moment(user.member_since).format('L') }}.
        最后访问 {{ moment(user.last_seen).fromNow() }}.
    </p>
</div>
{% endblock %}

在基础模板中,添加用户资料链接。

<!-- Example 10-6. app/templates/base.html: 在导航栏中添加用户资料链接 -->
{% if current_user.is_authenticated %}
<li>
    <a href="{{ url_for('main.user', username=current_user.username) }}">
        我的资料
    </a>
</li>
{% endif %}

如果你从 Github 克隆了示例代码,可以使用 git checkout 10a 签出本示例。

修改资料

有两种途径修改资料。一种是用户修改自己的资料;另一种是管理员修改任意用户的资料。由于这两种途径差别很大,所以需要创建两个表单类。

用户修改自己的资料

# 示例 10-7. app/main/forms.py: 用户级别的修改资料表单类
class EditProfileForm(FlaskForm):
    name = StringField('真实姓名', validators=[Length(0, 64)])
    location = StringField('位置', validators=[Length(0, 64)])
    about_me = TextAreaField('自我介绍')
    submit = SubmitField('提交')

注意,该表单类中的所有字段都是可选的。

对应的路由定义如下。

# 示例 10-8. app/main/views.py: 用户级别的修改资料路由
@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('你的资料已修改成功.')
        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)

【wrdll 注】作者在第11章 的示例11-3才对current_user._get_current_object() 进行说明

为了方便用户修改资料,需要将该路由添加到用户资料模板文件中。

<!-- 示例 10-9. app/templates/user.html: 修改资料链接 -->
{% if user == current_user %}
<a class="btn btn-default" href="{{ url_for('.edit_profile') }}">
 修改资料
</a>
{% endif %}

管理员修改任意用户资料

管理员修改任意用户资料比用户自行修改自身资料要复杂的多。至少多了三个用户信息字段:Email、用户名(username)和确认状态(confirmed)。示例10-10展示了该表单类。

# 示例 10-10. app/main/forms.py: 管理员修改用户资料的表单类
class EditProfileAdminForm(FlaskForm):
    email = StringField('Email', validators=[DataRequired(), Length(1, 64),
                                             Email()])
    username = StringField('用户名', validators=[
        DataRequired(), Length(1, 64),
        Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0, '用户名只能包含字母、数字、点或下划线')])
    confirmed = BooleanField('已确认')
    role = SelectField('角色', coerce=int)
    name = StringField('真实姓名', validators=[Length(0, 64)])
    location = StringField('位置', validators=[Length(0, 64)])
    about_me = TextAreaField('自我介绍')
    submit = SubmitField('提交')

    def __init__(self, user, *args, **kwargs):
        super(EditProfileAdminForm, self).__init__(*args, **kwargs)
        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 已被注册.')

    def validate_username(self, field):
        if field.data != self.user.username and \
                User.query.filter_by(username=field.data).first():
            raise ValidationError('用户名已被注册.')

SelectField 是对 HTML 下拉列表(<select> 标签)的包装,它有两个额外的参数。一个是 coerce=int,设置其值为整型,默认为字符串。另一个 choices,用于渲染下拉列表选项(<option> 标签)的 Python 列表,列表中的每一项都是一个包含两个元素的元组。元组中的第一个元素用于设置选项的值(<option>value 属性),元组的第二个元素用于设置选项的文本。

其对应的路由如下:

# 示例 10-11. app/main/views.py: 管理员修改用户资料的路由
from ..decorators import admin_required

@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('用户资料已更新.')
        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)

注意该视图函数的装饰器链的顺序

之后,在用户资料页面加上该页面的链接:

<!-- 示例 10-12. app/templates/user.html: 用户资料页面加上管理员修改资料的链接 -->
{% if current_user.is_administrator() %}
<a class="btn btn-danger"
        href="{{ url_for('.edit_profile_admin', id=user.id) }}">
    修改资料 [管理员]
</a>
{% endif %}

如果你从 Github 克隆了示例代码,可以使用 git checkout 10b 签出本示例。

用户头像

用户资料页面可以通过添加用户头像来进一步改善。本节你将学习到如何使用 Gravatar 提供的头像服务,为用户添加头像。Gravatar 通过用户的 Email 地址来生成头像,它接收经过 MD5 之后的 Email 地址作为参数。可以通过以下方法来计算 Email 的 MD5 值:

(venv) $ python
>>> import hashlib
>>> hashlib.md5('[email protected]'.encode('utf-8')).hexdigest()
'd4c74594d841139328695756648b6bd6'

将该 MD5 值附加到 https://secure.gravatar.com/avatar/ 之后,比如,https://secure.gravatar.com/avatar/d4c74594d841139328695756648b6bd6。然后使用 GET 方式请求该 URL,即可看到对应的头像。如果该 Email 并未在 Gravatar 注册过,则返回一个默认头像。在 Email 的 MD5 值后面,还可以附加额外的参数:

  • s:头像的大小,单位为像素
  • r:图片的评级。可用选项包括:g, pg, r, x
  • d:如果该 Email 没有在 Gravatar 注册过,返回的默认头像。选项有:404:返回404错误。此外还有 mm, identicon, monsterid, wavatar, retro, blank
  • fd:强制使用默认头像
# 示例 10-13. app/models.py: 生成 gravatar URL
import hashlib
from flask import request

class User(UserMixin, db.Model):
    # ...
    def gravatar(self, size=100, default='identicon', rating='g'):
        url = 'https://secure.gravatar.com/avatar'
        hash = hashlib.md5(self.email.lower().encode('utf-8')).hexdigest()
        return '{url}/{hash}?s={size}&d={default}&r={rating}'.format(
            url=url, hash=hash, size=size, default=default, rating=rating)

User 模型类的 gravatar() 也可以在 Jinja2 模板中使用。

<!-- 示例 10-14. app/templates/user.html: 在用户资料页面添加头像 -->
...
<img class="img-rounded profile-thumbnail" src="{{ user.gravatar(size=256) }}">
<div class="profile-header">
   ...
</div>
...

使用类似的方法,在基本模板的导航栏中添加已登录用户的头像的缩略图。为了让该缩略图更美观,需要自定义 CSS 样式。这个 CSS 样式,可以在 git 仓库里的 style.css 里找到,你还会看到 base.html 如何引用引用这个样式。

如果你从 Github 克隆了示例代码,可以使用 git checkout 10c 签出本示例。

生成头像所需的 MD5 是一个 CPU 密集型操作。如果有大量的头像要生成,将严重影响服务器性能。由于 MD5 总是和相对不变的 Email 保持一致,所以可以在 User 模型中进行缓存。

# 示例 10-15. app/models.py: 缓存 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'):
        if request.is_secure:
            url = 'https://secure.gravatar.com/avatar'
        else:
            url = 'http://www.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)

如果你从 Github 克隆了示例代码,可以使用 git checkout 10d 签出本示例。

在下一章中,将创建支持此应用程序的博客引擎。

本文并非《Flask Web Development(2nd edition)》的全文翻译。而只是在阅读该书时,对自认为重点的内容进行记录以及思考。由于水平有限,本文所述内容难免出现不足或错误。请通过购买正版图书来进行更系统地学习,原书地址。本站将依照本系列文章录制视频教程,届时本文未作记录的部分以及项目实际开发中所需要的知识都会在视频教程中进行补充。

flask Flask Web Development(2nd edition) 2018-03-27 19:49 2182606