所有的社交网站都会给用户提供资料页面,其中简要显示了用户在网站中的活动状况,用户可以把资料页面的URL分享给别人,以此宣告自己在这个网站上,因此,这个页面的URL要简短易记
资料信息
为了让用户的资料页面更吸引人,我们可以在其中添加一些关于用户的其他信息,下例扩充了User模型,添加了几个新字段:
# 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
后面没有()
,因为db.Column()
的default
参数可以接受函数作为默认值,所以每次需要生成默认值时,db.Column(
)都会调用指定的函数,member_since
字段只需要默认值即可
last_seen字段创建时的初始值也是当前时间,但用户每次访问网站后,这个值都会被刷新,我们可以在User模型中添加一个方法完成这个操作:
def ping(self):
self.last_seen = datetime.utcnow()
db.session.add(self)
每次收到用户的请求时都要调用ping()
方法,由于auth蓝本中的before_app_request
处理程序会在每次请求前运行,所以能很轻松的实现这个需求,如下所示:
#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[:5] != 'auth.':
return redirect(url_for('auth.unconfirmed'))
用户资料页面
为每个用户都创建资料页面没有什么难度,下例显示了路由定义
@main.route('/user/<username>')
def user(username):
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
return render_template('user.html', user=user)
这个路由在main蓝本中添加,对于名为john的用户,其资料页面的地址是
http://localhost:5000/user/john
,这个视图函数会在数据库中搜索URL中指定的用户名,如果找到,则渲染模板user.html
,并把用户名作为参数传入模板,如果传入路由的用户名不存在,则返回404错误,user.html
模板应该渲染保存在用户对象中的信息,这个模板的初始版本如下:
{% extends 'base.html' %}
{% 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.if_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>
</div>
{% endblock %}
在这个模板有有几处细节:
- name和location字段在同一个
<p>
元素中渲染,只有至少定义了这两个字段中的一个时,<p>
元素才会创建- 用户的location字段被渲染成指向谷歌地图的查询链接
- 如果登录用户是管理员,那么就显示用户的电子邮件地址,且渲染成mailto链接
大多数用户都希望能很轻松的访问自己的资料页面,因此我们可以在导航条中添加一个链接,对base.html
模板所做的修改如下所示:
# app/templates/base.html
{% if current_user.is_authenticated %}
<li>
<a href="{{ url_for('main.user', username=current_user.username)}}">关于我</a></li>
{% endif %}
把资料页面的链接包含在条件语句中是非常必要的,因为未认证的用户也能看到导航条,但我们不应该让他们看到资料页面的链接
资料编辑器
用户资料的编辑分两种情况,最显而易见的情况是,用户要进入一个页面并在其中输入自己的资料,而且这些内容显示在自己的资料页面上,还有一种不太明显但也同样重要的情况,那就是要让管理员能够编辑任意用户的资料,不仅要能编辑用户的个人信息,还要能编辑用户不能直接访问的User模型字段,例如用户角色,这两种编辑需求有本质上的区别,所以我们要创建两个不同的表单
用户级别的资料编辑器
普通用户的资料编辑表单如下:
class EditProfileForm(Form):
name = StringField('Real name', validators=[Length(0, 64)])
location = StringField('Location', validators=[Length(0, 64)])
about_me = TextAreaField('About me')
sumbit = SubmitField('Submit')
注意,这个表单中的所有字段都是可选的,因此长度验证函数允许长度为零
下例代码为显示这个表单的路由定义:
@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)
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)
在显示表单之前,这个视图函数为所有字段设定了初始值,对于所有给定字段,这一工作都是通过把初始值赋值给form.<filed-name>.data
完成的,当form.validate_on_submit()
返回False时,表单中的三个字段都使用current_user中保存的初始值,提交表单后,表单字段的data属性中保存有更新后的值,因此可以将其赋值给用户对象中的个字段,然后再把用户对象添加到数据库会话中
为了让用户能轻易找到编辑页面,我们可以在资料页面中添加一个链接:
# app/templates/user.html
{% if user == current_user %}
<a class='btn btn-default' href="{{ url_for('.edit_profile')}}">Edit Profile
</a>
链接外层的条件语句能确保只有当用户查看自己的资料页面时才显示这个链接
管理员级别的资料编辑器
管理员使用的资料编辑表单比普通用户的表单更加复杂, 除了前面的3个资料信息字段之外,管理员在表单中还要能编辑用户的电子邮件,用户名、确认状态和角色,其实现见下代码:
# app/main/forms.py
class EditProfileAdminForm(Form):
email = StringField('Email', validators=[Required(), Length(1, 64), Email()])
username = StringField('Username', validators=[
Required(), Length(1, 64), Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0
'Usernames must have only letters',
'numbers, dots or underscores'])
confirmed = BooleanField('Confirmed')
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')
sumbit = SubmitField('Submit')
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, filed):
if field.data != self.user.email and \
User.query.filter_by(email=filed.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.')
WTForms对HTML表单控件<select>
进行SelectField
包装,从而实现下拉列表,用来在这个表单中选择用户角色,SelectField
实例必须在其choices
属性中设置各选项,选项必须是一个由元组组成的列表,各元组都包含两个元素:选项的标识符和显示在控件中的文本字符串,choices
列表在表单的构造函数中设定,其值从Role
模型中获取,使用一个查询按照角色名的字母排序所有角色,元组中的标识符是角色的id
,因为这是个整数,所以在SelectField
构造函数中添加coerce=int参数
,从而把字段的值转换为整数,而不是用默认的字符串
email
和username
字段的构造方式和认证表单中的一样,但处理验证时需要更加小心,验证这两个字段时,首先要检查字段的值是否发生了变化,如果有变化,就要保证新值不和其他用户的相应字段值重复,如果字段值没有变化,应该跳过验证,为了实现这个逻辑,表单构造函数接收用户对象作为参数,并将其保存在成员变量中,随后自定义的验证方法要使用这个用户对象
管理员的资料编辑器路由定义如下:
# app/main/views.py
@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)
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)
这个路由和较简单的、普通用户的编辑路由具有基本相同的结构,在这个视图函数中,用户由id指定,因此可使用Flask-SQLAlchemy提供的get_or_404()
函数,如果提供的id不正确就会返回404错误
我们还需再探讨一下用于选择用户角色的SelectField,设定这个字段的初始值时,role_id被赋值给了field.role.data,这么做的原因在于choices属性中设置的元组列表使用数字标识符表示各选项,表单提交后,id从字段的data属性中提取,并且查询时会使用提取出来的id值加载角色对象,表单中声明SelectField时使用coerce=int参数,其作用是保证这个字段的data属性值是整数
为了链接到这个页面,我们还需在用户资料页面中添加一个链接按钮,如下所示:
# app/templates/user.html
{% if currrent_user.is_administrator() %}
<a class='btn btn-danger'
href="{{ url_for('.edit_profile_admin', id=user.id) }}">
Edit Profile [Admin]</a>
{% endif %}
为了醒目,这个按钮使用了不同的Bootstrap样式进行渲染,这里使用的条件语句确保只当登录用户为管理员时才显示该按钮
用户头像
通过显示用户的头像,我们可以进一步改进资料页面的外观,Gravatar是一个行业领先的头像服务,能把头像和电子邮件地址相关联起来,用户先要到http://gravatar.com
中注册账户,然后上传图片,生成头像的URL时,要计算电子邮件地址的MD5散列值
>>> import hashlib
>>> hashlib.md5('bangys@126.com'.encode('utf-8')).hexdigest()
'79d0f3203628df8df0de551f3e7466b5'
生成的头像URL是在http://www.gravatar.com/avatar
或https://secure.gravatar.com/avatar/
之后加上这个MD5散列值,例如你在浏览器的地址栏中输入http://www.gravatar.com/avatar/79d0f3203628df8df0de551f3e7466b5
,就会看到电子邮件地址bangys@126.com
对应的头像图片,如果这个电子邮件没有对应的头像,则会显示一个默认图片,头像URL的查询字符串中可以包含多个参数以配置头像图片的特征,可设参数如下:
参数名 | 说明 |
---|---|
s | 图片大小,单位为像素 |
r | 图片级别,可选值有’g’、’pg’、’r’和’x’ |
d | 没有注册Gravatar服务的用户使用的默认图片生成方式,可选值有:’404’,返回404错误;默认图片的URL; |
d | (图片生成器)’mm’、’identicon’、’monsterid’、’wavatar’、’retro’或’blank’之一 |
fd | 强制使用默认头像 |
我们可将构建Gravatar URL方法添加到User模型中,实现方法如下:
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 = hashlib.md5(self.email.encode('utf-8')).hexdigest()
return '{url}/{hash}?s={size}&d{default}&r{rating}'.format(
url=url, hash=hash, size=size, default=default, rating=rating)
这一实现会选择标准的或加密的Gravatar URL基以匹配用户的安全需求,头像的URL由URL基、用户电子邮件地址的MD5散列值和参数组成,而且各参数都设定了默认值,有了上述实现,我们就可以在Python shell里轻易生成头像URL了:
>>> u = User(email='bangys@126.com')
>>> u.gravatar()
'http://www.gravatar.com/avatar/79d0f3203628df8df0de551f3e7466b5?s=100&didenticon&rg'
>>> u.gravatar(size=256)
'http://www.gravatar.com/avatar/79d0f3203628df8df0de551f3e7466b5?s=256&didenticon&rg'
gravatar()
方法也可以在Jinja2模板中调用,下例代码在资料页面中添加了一个大小为256像素的头像:
# app/templates/user.html
#...
<img class='img-rounded profile-thumbnail' src="{{ user.gravatar(size=256) }}">
使用类似方式,我们可在基模板的导航条上添加一个已登录用户头像的小型缩略图,为了更好地调整页面中头像图片的显示格式,我们可以使用一些自定义的CSS类,我们可以在源码仓库的styles.css
文件中查看自定义的CSS,styles.css
文件保存在程序静态文件的文件夹中,而且要在base.html
模板中引用
生成头像时要生成MD5值,这是一项cpu密集型操作,如果要在某个页面中生成大量头像,计算量会非常大,由于用户电子邮件地址的MD5散列值是不变的,因此可以将其缓存在User模型中,若要把MD5散列值保存在数据库中,需要对User模型做些改动,如下:
# app/models.py
avatar_hash = db.Column(db.String(32))
class User(UserMixin, db.Model):
#....
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 hashlib.md5(self.email.encode('utf-8')).hexdigest()
return '{url}/{hash}?s={size}&d{default}&r{rating}'.format(
url=url, hash=hash, size=size, default=default, rating=rating)
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(permissions=0xff).first()
if self.role is None:
self.role = Role.query.filter_by(default=True).first()
if self.email is not None and self.avatar_hash is None:
self.avatar_hash = hashlib.md5(
self.email.encode('utf-8')).hexdigest()
def change_email(self, token):
self.email = new_email
self.avatar_hash = hashlib.md5(
self.email.encode('utf-8')).hexdigest()
db.session.add(self)
return True
模型初始化过程中会计算电子邮件的散列值,然后存入数据库,若用户更新了电子邮件地址,则会重新计算散列值,gravatar()
方法会使用模型中保存的散列值,如果模型中没有,就和之前一样计算电子邮件地址的散列值