本文翻译自 The Flask Mega-Tutorial Part VI: Profile Page and Avatars

这是Flask Mega-Tutorial系列的第六部分,我将告诉你如何创建个人主页。

本章将致力于为应用添加个人主页。个人主页用来展示用户的相关信息,其个人信息由本人录入。 我将为你展示如何动态地生成每个用户的主页,并提供一个编辑页面给他们来更新个人信息。

本章的GitHub链接为:Browse, Zip, Diff.

个人主页

作为创建个人主页的第一步,让我们为其URL /user/ 新建一个对应的视图函数。

  1. @app.route('/user/<username>')
  2. @login_required
  3. def user(username):
  4. user = User.query.filter_by(username=username).first_or_404()
  5. posts = [
  6. {'author': user, 'body': 'Test post #1'},
  7. {'author': user, 'body': 'Test post #2'}
  8. ]
  9. return render_template('user.html', user=user, posts=posts)

我用来装饰该视图函数的@app.route装饰器看起来和之前的有点不一样。 本例中被<>包裹的URL <username>是动态的。 当一个路由包含动态组件时,Flask将接受该部分URL中的任何文本,并将以实际文本作为参数调用该视图函数。 例如,如果客户端浏览器请求URL /user/susan,则视图函数将被调用,其参数username被设置为'susan'。 因为这个视图函数只能被已登录的用户访问,所以我添加了@login_required装饰器。

这个视图函数的实现相当简单。 我首先会尝试在数据库中以用户名来查询和加载用户。 之前你见过通过调用all()来得到所有的结果的查询,或是调用first()来得到结果中的第一个或者结果集为空时返回None的查询。 在本视图函数中,我使用了first()的变种方法,名为first_or_404(),当有结果时它的工作方式与first()完全相同,但是在没有结果的情况下会自动发送404 error给客户端。 以这种方式执行查询,我省去检查用户是否返回的步骤,因为当用户名不存在于数据库中时,函数将不会返回,而是会引发404异常。

如果执行数据库查询没有触发404错误,那么这意味着找到了具有给定用户名的用户。 接下来,我为这个用户初始化一个虚拟的用户动态列表,最后用传入的用户对象和用户动态列表渲染一个新的user.html模板。

user.html模板如下所示:

  1. {% extends "base.html" %}
  2. {% block content %}
  3. <h1>User: {{ user.username }}</h1>
  4. <hr>
  5. {% for post in posts %}
  6. <p>
  7. {{ post.author.username }} says: <b>{{ post.body }}</b>
  8. </p>
  9. {% endfor %}
  10. {% endblock %}

个人主页虽然已经完成了,但是网站上却没有一个入口链接。我将会在顶部的导航栏中添加这个入口链接,以便用户可以轻松查看自己的个人资料:

  1. <div>
  2. Microblog:
  3. <a href="{{ url_for('index') }}">Home</a>
  4. {% if current_user.is_anonymous %}
  5. <a href="{{ url_for('login') }}">Login</a>
  6. {% else %}
  7. <a href="{{ url_for('user', username=current_user.username) }}">Profile</a>
  8. <a href="{{ url_for('logout') }}">Logout</a>
  9. {% endif %}
  10. </div>

这里唯一有趣的变化是用来生成链接到个人主页的url_for()调用。 由于个人主页视图函数接受一个动态参数,所以url_for()函数接收一个值作为关键字参数。 由于这是一个指向当前登录个人主页的链接,我可以使用Flask-Login的current_user对象来生成正确的URL。

个人主页

尝试点击顶部的Profile链接就能将你带到自己的个人主页。 此时,虽然没有链接来访问其他用户的主页,但是如果要访问这些页面,则可以在浏览器的地址栏中手动输入网址。 例如,如果你在应用中注册了名为“john”的用户,则可以通过在地址栏中键入http:// localhost:5000/user/john来查看该用户的个人主页。

头像

我相信你也觉得我刚刚建立的个人主页非常枯燥乏味。为了使它们更加有趣,我将添加用户头像。与其在服务器上处理大量的上传图片,我将使用Gravatar为所有用户提供图片服务。

Gravatar服务使用起来非常简单。 要请求给定用户的图片,使用格式为https://www.gravatar.com/avatar/的URL即可,其中<hash>是用户的电子邮件地址的MD5哈希值。 在下面,你可以看到如何生成电子邮件为john@example.com的用户的Gravatar URL:

  1. >>> from hashlib import md5
  2. >>> 'https://www.gravatar.com/avatar/' + md5(b'john@example.com').hexdigest()
  3. 'https://www.gravatar.com/avatar/d4c74594d841139328695756648b6bd6'

如果你想看一个实际的例子,我自己的Gravatar URL是https://www.gravatar.com/avatar/729e26a2a2c7ff24a71958d4aa4e5f35。Gravatar返回的图片如下:
Miguel的头像

默认情况下,返回的图像大小是80x80像素,但可以通过向URL的查询字符串添加s参数来请求不同大小的图片。 例如,要获得我自己128x128像素的头像,该URL是https://www.gravatar.com/avatar/729e26a2a2c7ff24a71958d4aa4e5f35?s=128

另一个可传递给Gravatar的有趣参数是d,它让Gravatar为没有向服务注册头像的用户提供的随机头像。 我最喜欢的随机头像类型是“identicon”,它为每个邮箱都返回一个漂亮且不重复的几何设计图片。 如下:
Identicon头像

请注意,一些Web浏览器插件(如Ghostery)会屏蔽Gravatar图像,因为它们认为Automattic(Gravatar服务的所有者)可以根据你发送的获取头像的请求来判断你正在访问的网站。 如果在浏览器中看不到头像,你在排查问题的时候可以考虑以下是否在浏览器中安装了此类插件。

由于头像与用户相关联,所以将生成头像URL的逻辑添加到用户模型是有道理的。

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

User类新增的avatar()方法需要传入需求头像的像素大小,并返回用户头像图片的URL。 对于没有注册头像的用户,将生成“identicon”类的随机图片。 为了生成MD5哈希值,我首先将电子邮件转换为小写,因为这是Gravatar服务所要求的。 然后,因为Python中的MD5的参数类型需要是字节而不是字符串,所以在将字符串传递给该函数之前,需要将字符串编码为字节。

如果你对Gravatar服务很有兴趣,可以学习他们的文档

下一步需要将头像图片插入到个人主页的模板中:

  1. {% extends "base.html" %}
  2. {% block content %}
  3. <table>
  4. <tr valign="top">
  5. <td><img src="{{ user.avatar(128) }}"></td>
  6. <td><h1>User: {{ user.username }}</h1></td>
  7. </tr>
  8. </table>
  9. <hr>
  10. {% for post in posts %}
  11. <p>
  12. {{ post.author.username }} says: <b>{{ post.body }}</b>
  13. </p>
  14. {% endfor %}
  15. {% endblock %}

使用User类来返回头像URL的好处是,如果有一天我不想继续使用Gravatar头像了,我可以重写avatar()方法来返回其他头像服务网站的URL,所有的模板将自动显示新的头像。

我的个人主页的顶部有一个不错的大头像,不止如此,底下的所有用户动态都会有一个小头像。 对于个人主页而言,所有的头像当然都是对应用户的。我将会在主页面上实现每个用户动态都用其作者的头像来装饰,这样一来看起来就非常棒了。

为了显示用户动态的头像,我只需要在模板中进行一个小小的更改:

  1. {% extends "base.html" %}
  2. {% block content %}
  3. <table>
  4. <tr valign="top">
  5. <td><img src="{{ user.avatar(128) }}"></td>
  6. <td><h1>User: {{ user.username }}</h1></td>
  7. </tr>
  8. </table>
  9. <hr>
  10. {% for post in posts %}
  11. <table>
  12. <tr valign="top">
  13. <td><img src="{{ post.author.avatar(36) }}"></td>
  14. <td>{{ post.author.username }} says:<br>{{ post.body }}</td>
  15. </tr>
  16. </table>
  17. {% endfor %}
  18. {% endblock %}

头像

使用Jinja2子模板

我设计的个人主页,使用头像和文字组合的方式来展示了用户动态。 现在我想在主页也使用类似的风格来布局。 我可以复制/粘贴来处理用户动态渲染的模板部分,但这实际上并不理想,因为之后如果我想要对此布局进行更改,我将不得不记住要更新两个模板。

取而代之,我要创建一个只渲染一条用户动态的子模板,然后在user.htmlindex.html模板中引用它。 首先,我要创建这个只有一条用户动态HTML元素的子模板。 我将其命名为app/templates/_post.html_前缀只是一个命名约定,可以帮助我识别哪些模板文件是子模板。

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

我在user.html模板中使用了Jinja2的include语句来调用该子模板:

  1. {% extends "base.html" %}
  2. {% block content %}
  3. <table>
  4. <tr valign="top">
  5. <td><img src="{{ user.avatar(128) }}"></td>
  6. <td><h1>User: {{ user.username }}</h1></td>
  7. </tr>
  8. </table>
  9. <hr>
  10. {% for post in posts %}
  11. {% include '_post.html' %}
  12. {% endfor %}
  13. {% endblock %}

应用的主页还没有完善,所以现在我不打算在其中添加这个功能。

更多有趣的个人资料

新增的个人主页存在的一个问题是,真正显示的内容不够丰富。 用户喜欢在个人主页上展示他们的相关信息,所以我会让他们写一些自我介绍并在这里展示。 我也将跟踪每个用户最后一次访问该网站的时间,并显示在他们的个人主页上。

为了支持所有这些额外的信息,首先需要做的是用两个新的字段扩展数据库中的用户表:

  1. class User(UserMixin, db.Model):
  2. # ...
  3. about_me = db.Column(db.String(140))
  4. last_seen = db.Column(db.DateTime, default=datetime.utcnow)

每次数据库被修改时,都需要生成数据库迁移。 在第四章中,我向你展示了如何设置应用以通过迁移脚本跟踪数据库的变更。 现在有两个新的字段我想添加到数据库中,所以第一步是生成迁移脚本:

  1. (venv) $ flask db migrate -m "new fields in user model"
  2. INFO [alembic.runtime.migration] Context impl SQLiteImpl.
  3. INFO [alembic.runtime.migration] Will assume non-transactional DDL.
  4. INFO [alembic.autogenerate.compare] Detected added column 'user.about_me'
  5. INFO [alembic.autogenerate.compare] Detected added column 'user.last_seen'
  6. Generating /home/miguel/microblog/migrations/versions/37f06a334dbf_new_fields_in_user_model.py ... done

migrate命令的输出表示一切正确运行,因为它显示User类中的两个新字段已被检测到。 现在我可以将此更改应用于数据库:

  1. (venv) $ flask db upgrade
  2. INFO [alembic.runtime.migration] Context impl SQLiteImpl.
  3. INFO [alembic.runtime.migration] Will assume non-transactional DDL.
  4. INFO [alembic.runtime.migration] Running upgrade 780739b227a7 -> 37f06a334dbf, new fields in user model

我希望你认识到使用迁移框架是多么有用。 数据库中的用户数据仍然存在,迁移框架如同实施手术教学般地精准执行迁移脚本中的更改并且不损坏任何数据。

下一步,我将会把新增的两个字段增加到个人主页中:

  1. {% extends "base.html" %}
  2. {% block content %}
  3. <table>
  4. <tr valign="top">
  5. <td><img src="{{ user.avatar(128) }}"></td>
  6. <td>
  7. <h1>User: {{ user.username }}</h1>
  8. {% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
  9. {% if user.last_seen %}<p>Last seen on: {{ user.last_seen }}</p>{% endif %}
  10. </td>
  11. </tr>
  12. </table>
  13. ...
  14. {% endblock %}

请注意,我用Jinja2的条件语句来封装了这两个字段,因为我只希望它们在设置后才可见。 目前,所有用户的这两个字段都是空的,所以如果现在运行应用,则不会看到这些字段。

记录用户的最后访问时间

让我们从更容易实现的last_seen字段开始。 我想要做的就是一旦某个用户向服务器发送请求,就将当前时间写入到这个字段。

为每个视图函数添加更新这个字段的逻辑,这么做非常的枯燥乏味。在视图函数处理请求之前执行一段简单的代码逻辑在Web应用中十分常见,因此Flask提供了一个内置功能来实现它。解决方案如下:

  1. from datetime import datetime
  2. @app.before_request
  3. def before_request():
  4. if current_user.is_authenticated:
  5. current_user.last_seen = datetime.utcnow()
  6. db.session.commit()

Flask中的@before_request装饰器注册在视图函数之前执行的函数。这是非常有用的,因为现在我可以在一处地方编写代码,并让它在任何视图函数之前被执行。该代码简单地实现了检查current_user是否已经登录,并在已登录的情况下将last_seen字段设置为当前时间。我之前提到过,应用应该以一致的时间单位工作,标准做法是使用UTC时区,使用系统的本地时间不是一个好主意,因为如果那么的话,数据库中存储的时间取决于你的时区。最后一步是提交数据库会话,以便将上面所做的更改写入数据库。如果你想知道为什么在提交之前没有db.session.add(),考虑在引用current_user时,Flask-Login将调用用户加载函数,该函数将运行一个数据库查询并将目标用户添加到数据库会话中。所以你可以在这个函数中再次添加用户,但是这不是必须的,因为它已经在那里了。

如果在进行此更改后查看你的个人主页,则会看到“Last seen on”行,并且时间非常接近当前时间。 如果你离开个人主页,然后返回,你会看到时间在不断更新。

事实上,我在存储时间和在个人主页显示时间的时候,使用的都是UTC时区。 除此之外,显示的时间格式也可能不是你所预期的,因为实际上它是Python datetime对象的内部表示。 现在,我不会操心这两个问题,因为我将在后面的章节中讨论在Web应用中处理日期和时间的主题。
最后访问时间

个人资料编辑器

我还需要给用户一个表单,让他们输入一些个人资料。 表单将允许用户更改他们的用户名,并且写一些个人介绍,以存储在新的about_me字段中。 让我们开始为它写一个表单类吧:

  1. from wtforms import StringField, TextAreaField, SubmitField
  2. from wtforms.validators import DataRequired, Length
  3. # ...
  4. class EditProfileForm(FlaskForm):
  5. username = StringField('Username', validators=[DataRequired()])
  6. about_me = TextAreaField('About me', validators=[Length(min=0, max=140)])
  7. submit = SubmitField('Submit')

我在这个表单中使用了一个新的字段类型和一个新的验证器。 对于“about_me”字段,我使用TextAreaField,这是一个多行输入文本框,用户可以在其中输入文本。 为了验证这个字段的长度,我使用了Length,它将确保输入的文本在0到140个字符之间,因为这是我为数据库中的相应字段分配的空间。

该表单的渲染模板代码如下:

  1. {% extends "base.html" %}
  2. {% block content %}
  3. <h1>Edit Profile</h1>
  4. <form action="" method="post">
  5. {{ form.hidden_tag() }}
  6. <p>
  7. {{ form.username.label }}<br>
  8. {{ form.username(size=32) }}<br>
  9. {% for error in form.username.errors %}
  10. <span style="color: red;">[{{ error }}]</span>
  11. {% endfor %}
  12. </p>
  13. <p>
  14. {{ form.about_me.label }}<br>
  15. {{ form.about_me(cols=50, rows=4) }}<br>
  16. {% for error in form.about_me.errors %}
  17. <span style="color: red;">[{{ error }}]</span>
  18. {% endfor %}
  19. </p>
  20. <p>{{ form.submit() }}</p>
  21. </form>
  22. {% endblock %}

最后一步,使用视图函数将它们结合起来:

  1. from app.forms import EditProfileForm
  2. @app.route('/edit_profile', methods=['GET', 'POST'])
  3. @login_required
  4. def edit_profile():
  5. form = EditProfileForm()
  6. if form.validate_on_submit():
  7. current_user.username = form.username.data
  8. current_user.about_me = form.about_me.data
  9. db.session.commit()
  10. flash('Your changes have been saved.')
  11. return redirect(url_for('edit_profile'))
  12. elif request.method == 'GET':
  13. form.username.data = current_user.username
  14. form.about_me.data = current_user.about_me
  15. return render_template('edit_profile.html', title='Edit Profile',
  16. form=form)

这个视图函数处理表单的方式和其他的视图函数略有不同。如果validate_on_submit()返回True,我将表单中的数据复制到用户对象中,然后将对象写入数据库。但是当validate_on_submit()返回False时,可能是由于两个不同的原因。这可能是因为浏览器刚刚发送了一个GET请求,我需要通过提供表单模板的初始版本来响应。也可能是这种情况,浏览器发送带有表单数据的POST请求,但该数据中的某些内容无效。对于该表单,我需要区别对待这两种情况。当第一次请求表单时,我用存储在数据库中的数据预填充字段,所以我需要做与提交相反的事情,那就是将存储在用户字段中的数据移动到表单中,这将确保这些表单字段具有用户的当前数据。但在验证错误的情况下,我不想写任何表单字段,因为它们已经由WTForms填充了。为了区分这两种情况,我需要检查request.method,如果它是GET,这是初始请求的情况,如果是POST则是提交表单验证失败的情况。

个人资料编辑器

我将个人资料编辑页面的链接添加到个人主页,以便用户使用:

  1. {% if user == current_user %}
  2. <p><a href="{{ url_for('edit_profile') }}">Edit your profile</a></p>
  3. {% endif %}

请注意我巧妙使用的条件,它确保在查看自己的个人主页时出现编辑个人资料的链接,而在查看其他人的个人主页时不会出现。

个人主页和编辑链接