一个web应用的诞生(9)--回到用户

时间:2023-12-11 23:56:56

在开始之前,我们首先根据之前的内容想象一个场景,用户张三在网上浏览,看到了这个轻博客,发现了感兴趣的内容,于是想要为大家分享一下心情,恩?发现需要注册,好,输入用户名,密码,邮箱,并上传头像后,就可以愉快的和大家进行分享互动了。

这是一个很好的场景,不是么,下面我们就要来实现它,首先来说,存储一张图片有多重方法,服务器本地存储,db中存储二进制,但是这些都会或多或少的占用服务器的空间,并且,图片的读写还会占用空间宝贵的流量,对于我来说,一个穷coder,用的服务器是最便宜的一款阿里云,所以空间能省就省,而流量,更是节约到底,毕竟阿里云的流量比空间还要贵。

最节省的方式当然是使用免费的专有空间来存储图片了,幸运的是,确实有这样一种看上去很天方夜谭的方式,那就是使用七牛云,当然了,免费使用七牛云的话,比如不能绑定域名,单ip访问频次限制等,但现阶段来说已经是够用了。

使用七牛云的方法看上去和之前没什么区别,第一项当然还是安装:

pip3.6 install qiniu

然后进行注册:

from qiniu import Auth
... qn=Auth(access_key,secret_key)

很简单,其实这里使用的只是一个获取token,而文件上传的部分使用js-jdk来实现,现在增加一个获取token的视图:

#获取七牛凭证
@main.route("/qiniuuptoken",methods=["GET","POST"])
def qiniuuptoken():
bucket_name="python-nblog"
key=str(uuid.uuid1())
token=qn.upload_token(bucket_name,key)
return jsonify({
"uptoken":token,
"key":key
})

使用一个uuid作为云端的文件名,并且将此uuid与用户绑定存入db中作为用户的头像使用

然后修改用户对象,新增headimg字段(存储文件key):

class User(UserMixin,db.Model):
__tablename__="users"
...
headimg=db.column(db.String(50))
...

好了,还记得之前实现的功能么,下面要修改RegisterForm类,在表单中新增一个上传头像的file域,以及一个用于记录图片key的隐藏域

class RegisterForm(Form):
...
headimg=FileField("上传头像")
headkey=HiddenField("头像上传后生成的key")
...
submit=SubmitField("提交")

修改register.html模板,增加js文件的引用块:

{% block scripts %}
{{super()}}
<script src="http://cdn.bootcss.com/plupload/2.1.9/moxie.min.js"></script>
<script src="http://cdn.bootcss.com/plupload/2.1.9/plupload.min.js"></script>
<script src="http://cdn.bootcss.com/plupload/2.1.9/i18n/zh_CN.js"></script>
<script src="http://cdn.bootcss.com/qiniu-js/1.0.17.1/qiniu.min.js"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/qiniuupload.js',key=12) }}"></script>
{% endblock %}

引用的js文件貌似还不少,可能也看到了,自己使用的就是qiniuupload.js,代码如下:

$(function () {
var tempurl="http://on4ag3uf5.bkt.clouddn.com";//常量 七牛临时域名地址 var token={
key:"",
uptoken:""
}
//img回写
if($("#headkey").val()!=""){
reSetImg(tempurl)
}
var uploader = Qiniu.uploader({
runtimes: 'html5', // 上传模式,依次退化
browse_button: 'headimg', // 上传选择的点选按钮,必需
uptoken_func: function(file){ // 在需要获取uptoken时,该方法会被调用
$.getJSON({url:"/qiniuuptoken",type:"POST",async:false,success:function (d) {
token.up= d.uptoken;
token.key=d.key;
}})
return token.up;
},
get_new_uptoken: false, // 设置上传文件的时候是否每次都重新获取新的uptoken
domain: 'python-nblog', // bucket域名,下载资源时用到,必需
//container: 'container', // 上传区域DOM ID,默认是browser_button的父元素
max_file_size: '5mb', // 最大文件体积限制
flash_swf_url: 'http://cdn.bootcss.com/plupload/3.1.0/Moxie.swf', //引入flash,相对路径
max_retries: 3, // 上传失败最大重试次数
dragdrop: false, // 开启可拖曳上传
//drop_element: 'container', // 拖曳上传区域元素的ID,拖曳文件或文件夹后可触发上传
chunk_size: '1mb', // 分块上传时,每块的体积
auto_start: true, // 选择文件后自动上传,若关闭需要自己绑定事件触发上传
init: {
'FileUploaded': function(up, file, info) {
setImg(tempurl, $.parseJSON(info).key)
},
'Key': function(up, file) {
// do something with key here
return token.key
}
}
});
}); function setImg( tempurl,imgKey){
var temphtml="<div class='form-group'><label class='control-label'>头像预览</label>"
temphtml+="<div><img src='"+tempurl+"/"+imgKey+"' class='img-thumbnail' style='width:200px;height:200px;'></div>";
temphtml+="</div>"; //修改key
$("#headkey").val(imgKey)
//增加预览图
$("#headimg").parent().after(temphtml);
$("#headimg").hide();
}

代码不难懂,除了七牛部分,都是基本的jq代码,并且七牛的js-sdk都有很完善的demo和文档

七牛的使用步骤

1 注册七牛账户

2 点击新建存储空间如图示:

一个web应用的诞生(9)--回到用户

4 输入存储空间名称,必填,对应sdk中的domain字段

5 点击确定 即可

注意,由于使用的为免费用户,所以不能绑定域名,使用的为七牛分配域名。

然后,修改注册视图:

 if form.validate_on_submit():
...
user.headimg=form.headkey.data
...
user.role_id=1 #暂时约定公开用户角色为1
db.session.add(user)

最后修改base.html模板,将注册页的导航加入:

 <ul class="nav navbar-nav navbar-right">
{% if current_user.is_authenticated %}
<li><p class="navbar-text"><a href="#" class="navbar-link">{{current_user.username}}</a> 您好</p></li>
<li><a href="{{url_for('auth.logout')}}">登出</a></li>
{% else %}
<li><a href="{{url_for('auth.login')}}">登录</a></li>
<li><a href="{{url_for('auth.register')}}">注册</a></li>
{% endif %}
</ul>

功能宣告完成。

与这个功能类似的功能是用户资料的功能,即对用户资料的查看和修改,但这个功能需要用户权限来进行支撑,所以先来完成用户权限。

下面让我们回看之前的代码,user.role_id=1很扎眼对不对,下面完成一下权限系统,说是权限系统,其实只有三个角色:

  1. 匿名用户,即未登录用户,只有阅读权限
  2. 普通用户,具有发布文章,评论文章已经关注他人的权限
  3. 管理员,除普通用户外,还有删除及修改文章权限

这三个角色,对应到db中需要两条记录,即User和Administrator,下面对角色类进行适当的修改并增加初始化方法

class Role(db.Model):
__tablename__="roles"
id=db.Column(db.Integer,primary_key=True)
name=db.Column(db.String(50),unique=True)
users=db.relationship("User",backref='role')
default=db.Column(db.Boolean)
@staticmethod
def init_roles():
roles={
"User":('普通用户',True),
"Administrator":("管理员用户",False)
}
for r in roles:
print(r)
role=Role.query.filter_by(name=r[0]).first()
if role is None:
role=Role()
role.name=roles[r][0]
role.default=roles[r][1]
db.session.add(role)
db.session.commit()

增加了一个default字段,以绝定用户注册时使用此角色,并且增加了初始化方法,新增两个角色,执行初始化脚本:

python manage.py shell
>>>Role.init_roles()

为用户定义默认角色:

class User(UserMixin,db.Model):
def __init__(self,**kwargs):
super(User,self).__init__(**kwargs)
if self.role is None:
self.role=Role.query.filter_by(default=True).first();

通过User类的构造函数,来发现创建user类中是否已经定义了角色,如果没有定义则设置为默认角色。

然后继续创建一个匿名用户类:

class AnonymousUser(AnonymousUserMixin):
def is_administrator(self):
return self.role.admin

可以看到,此匿名用户类继承了Flask_login的AnonymousUserMixin类,并将其设置为匿名用的current_user的值,即未登录用户的current_user,以便程序中使用。

如果某些视图函数只对登录用户或管理员开发,当让可以在视图内判断,但更好的方式则是使用一个自定义的装饰器。

from functools import wraps
from flask import abort
from flask_login import current_user def admin_required(f):
@wraps(f)
def decorated_function(*args,**kwargs):
if not current_user.is_administrator():
abort(403)
return f(*args,**kwargs)
return decorated_function

装饰器使用了functools包,功能为如果用户不为管理员,则返回403错误,下面演示一下如何使用这个装饰器:

@main.route("/admin",methods=["GET","POST"])
@admin_required
def for_admin_only():
return "您好 管理员"

运行一下,还记得之前注册过的用户么,就使用zhangji这个用户好了,登录后直接在url中输入/admin,显示:

一个web应用的诞生(9)--回到用户

为了方便测试,直接将db中zhangji这个用户的role_id字段修改为管理员id,刷新页面:

一个web应用的诞生(9)--回到用户

ok,非常完美,接下来根据权限,完成首页内容:

首先,头像改为实际内容:

{% for post in posts %}
<div class="bs-callout
{% if loop.index % 2 ==0 %}
bs-callout-d
{% endif %}
{% if loop.last %}
bs-callout-last
{% endif %}" >
<div class="row">
<div class="col-sm-2 col-md-2">
<!--使用测试域名-->
<img src="http://on4ag3uf5.bkt.clouddn.com/{{post.author.headimg}}" alt="...">
</div>
<div class="col-sm-10 col-md-10">
<div>
<p>
{% if post.body_html%}
{{post.body_html|safe}}
{% else %}
{{post.body}}
{% endif %}
</p>
</div>
<div>
<a class="text-left" href="#">李四</a>
<span class="text-right">发表于&nbsp;{{ moment( post.createtime).fromNow(refresh=True)}}</span>
</div>
</div>
</div>
</div>
{% endfor %}

以及:

 <div class="col-md-4 col-md-4 col-lg-4">
<!--这里 当没有用户登录的时候 显示热门分享列表 稍后实现-->
{% if current_user.is_authenticated %}
<img src="http://on4ag3uf5.bkt.clouddn.com/{{current_user.headimg}}" alt="..." class="headimg img-thumbnail">
<br><br>
<p class="text-muted">我已经分享<span class="text-danger">55</span>条心情</p>
<p class="text-muted">我已经关注了<span class="text-danger">7</span>名好友</p>
<p class="text-muted">我已经被<span class="text-danger">8</span>名好友关注</p>
{%endif%}
</div>

关注部分稍后完成。

而如果没有登录,则是不能分享心情的,这时将表单隐藏即可

  <div>
{% if current_user.is_authenticated %}
{{ wtf.quick_form(form) }}
{% endif %}
</div>

最后,点击头像或姓名,还可以查看作者的资料,这个功能点分为三种情况:

  1. 其他人观看,会有一个样式美观的名片页
  2. 自己观看,则会产生名片页,并可以修改内容
  3. 管理员观看,则会产生名片页并可以修改内容

我们先来看其他人的个人资料页,首先,需要创建一个视图:

@main.route("/user/<username>")
def user(username):
user=User.query.filter_by(username=username).first()
if(user is None):
abort(404)
posts = Post.query.filter_by(author_id=user.id)
return render_template("user.html",user=user,posts=posts)

然后创建模板:

{% extends "base.html" %}
{% block main %}
<div class="container">
<div class="row">
<p>
<img src="http://on4ag3uf5.bkt.clouddn.com/{{user.headimg}}" alt="..." class="headimg img-thumbnail" style="width:300px; height: 300px">
</p>
<p>
{% if user.nickname%}{{user.nickname}}{%elif user.username %}{{ user.username }}{% endif %}
</p>
{% if user.username %}
<p>用户名:{{user.username}}</p>
{% endif %}
{% if user.username %}
<p>昵称:{{user.nickname}}</p>
{% endif %}
{% if user.email %}
<p>联系方式:<a href="mailto:{{user.email}}">{{user.email}}</a></p>
{% endif %}
{% if user.remark %}
<p>自我简介:{{user.remark}}</p>
{% endif %}
<p>
注册时间:{{moment(user.createtime).format('LL')}}
最终登录时间:{{moment(user.lastseen).format('LL')}}
</p>
</div>
</div>
{% endblock %}

你可能注意到createtime和lastseen两个字段,是基于一般的博客网站,新增加的内容:

class User(UserMixin,db.Model):
...
lastseen=db.Column(db.DateTime,default=datetime.utcnow)
createtime=db.Column(db.DateTime,default=datetime.utcnow)
...

分别在定义了注册时间和最后访问的时间

最后,为头像和作者的位置增加超链接(index.html):

  ...
<a class="text-left" href="{{url_for('main.user',username=post.author.username)}}">
<img src="http://on4ag3uf5.bkt.clouddn.com/{{post.author.headimg}}" alt="...">
</a>
...
<a class="text-left" href="{{url_for('main.user',username=post.author.username)}}">{{post.author.nickname}}</a>

接下来是自己进入和管理员进入,这时候如果还同样在这个页面进行操作,就会显得复杂,所以比较好的办法是如果是本用户或管理员的话,显示一个编辑的超链接,进行一下跳转进行编辑,同时,由于本用户进行编辑的话,只可以编辑有限几个字段,如生日,真实姓名,自我简介等,但是如果是管理员的话,显然会编辑很多自动,如用户名,权限配置等,所以,会创建两个超链接分别对应本用户的表单和管理员的表单(user.html)。

 <p>
{% if current_user.is_authenticated and current_user.username==user.username %}
<a href="#">修改个人信息</a>
{% endif %}
{% if current_user.is_administrator() %}
<a href="#">修改该用户信息</a>
{% endif %}
</p>

下面创建修改个人信息表单:

from flask_wtf import FlaskForm
from wtforms import FileField,HiddenField,StringField,DateField,RadioField,TextAreaField,SubmitField
from wtforms.validators import Email
class EditProfileForm(FlaskForm):
headimg = FileField("上传头像")
headkey = HiddenField("头像上传后生成的key")
nickname = StringField("昵称")
birthday = DateField("出生日期")
email = StringField("邮箱地址", validators=[Email()])
gender = RadioField("性别", choices=[("0", "男"), ("1", "女")], default=0,coerce=int)
remark = TextAreaField("自我简介")
submit = SubmitField("提交")

当修改的时候,头像要能够回写,在qiniuupload.js文件中的$(function(){})方法中增加如下方法:

//img回写
if($("#headkey").val()!=""){
reSetImg(tempurl)
}

并且添加reSetImg方法:

function reSetImg(tempurl) {
var temphtml="<div class='form-group'><label class='control-label'>头像预览</label>"
temphtml+="<div><img src='"+tempurl+"/"+$("#headkey").val()+"' class='img-thumbnail' style='width:200px;height:200px;'></div>";
temphtml+="</div>";
$("#headimg").parent().after(temphtml);
}

之前的头像还要删除掉:

function setImg( tempurl,imgKey){
var temphtml="<div class='form-group'><label class='control-label'>头像预览</label>"
temphtml+="<div><img src='"+tempurl+"/"+imgKey+"' class='img-thumbnail' style='width:200px;height:200px;'></div>";
temphtml+="</div>";
//删除之前的预览图
if($("#headimg").parent().next().find("img"))
{
$("#headimg").parent().next().remove()
}
//修改key
$("#headkey").val(imgKey)
//增加预览图
$("#headimg").parent().after(temphtml);
$("#headimg").hide();
}

注意这里删除仅仅是删除html中的dom,七牛中的文件并没有删除,毕竟不是专门针对七牛的blog 所以这个功能不打算实现,各位可以自己来实现此功能。

而html模板与注册模板基本一样:

{% extends "base.html"%}
{% block content %} <!--具体内容-->
{% import "bootstrap/wtf.html" as wtf %}
<div class="container">
<div class="row"></div>
<div class="row"> <div>
<div class="page-header">
<h1>修改个人信息</h1>
</div>
{% for message in get_flashed_messages() %}
<div class="alert alert-warning">
<button type="button" class="close" data-dismiss="alter">&times</button>
{{message}}
</div>
{% endfor %}
{{ wtf.quick_form(form)}}
</div>
</div>
</div>
{% endblock %} {% block scripts %}
{{super()}}
<script src="http://cdn.bootcss.com/plupload/2.1.9/moxie.min.js"></script>
<script src="http://cdn.bootcss.com/plupload/2.1.9/plupload.min.js"></script>
<script src="http://cdn.bootcss.com/plupload/2.1.9/i18n/zh_CN.js"></script>
<script src="http://cdn.bootcss.com/qiniu-js/1.0.17.1/qiniu.min.js"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/qiniuupload.js',key=01) }}"></script>
{% endblock %}

简单测试一下,非常完美,限于篇幅就不贴图,下面完成一下管理员对于普通用户的资料修改,相对于普通用户来说,管理员要能修改的项就要多一些了,下面创建一个用于管理员使用的表单:

from flask_wtf import FlaskForm
from wtforms import FileField,HiddenField,StringField,DateField,RadioField,TextAreaField,SubmitField,SelectField
from wtforms.validators import Email,ValidationError,DataRequired
from ..models.User import User
from ..models.Role import Role
class EditProfileAdminForm(FlaskForm):
headimg = FileField("上传头像")
headkey = HiddenField("头像上传后生成的key")
username=StringField("用户名",validators=[DataRequired()])
role=SelectField("用户角色",coerce=int)
nickname = StringField("昵称")
birthday = DateField("出生日期")
email = StringField("邮箱地址", validators=[Email()])
gender = RadioField("性别", choices=[(0, "男"), (1, "女")], default=0,coerce=int)
remark = 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.all()]
self.user=user; def validate_username(self,field):
if(field.data!=self.username and User.query.filter_by(username=field.data).first()):
raise ValidationError("此用户名已经使用!")

可以看到,就是在普通的修改页进行了一些修改,增加用户名和角色两个字段,并在构造函数中为角色下拉菜单注入值,主语注入的写法:

[(role.id,role.name) for role in Role.query.all()]

这种表达式的写法是我决定python中最帅的写法,虽然复杂的看着有点晕:(,和java中的拉姆达一样,其实应该说java中的拉姆达和他一样。还需要注意的一个就是自定义验证的写法,这个验证的功能是如果用户名进行了修改,并且与db中已有值相同,则会抛出异常,页面会提示此用户名已经使用,你一定想到了,其实注册的时候就应该做此验证的,同时对注册表单进行修改, 这里就不贴代码。

剩下的就非常简单,和本用户编辑几乎相同,甚至使用相同的模板,下面是视图控制器的代码:

@main.route("/edit-profile/<int:id>",methods=["GET","POST"])
@admin_required
@login_required
def edit_profile_admin(id):
user=User.query.get_or_404(id);
form=EditProfileAdminForm(user=user);
if form.validate_on_submit():
user.nickname=form.nickname.data
user.remark=form.remark.data
user.birthday=form.birthday.data
user.email=form.email.data
user.gender=form.gender.data
user.headimg=form.headkey.data
user.role=Role.query.get(form.role.data)
user.username=form.username.data
db.session.add(user)
return redirect(url_for("main.user",username=user.username))
form.nickname.data=user.nickname
form.remark.data=user.remark
form.birthday.data=user.birthday
form.email.data=user.email
form.gender.data=user.gender
form.headkey.data=user.headimg
form.role.data=user.role_id
form.username.data=user.username
return render_template("edit_profile.html",form=form,user=user);

注意此时使用id进行用户检索,则可以使用get_or_404方法,当查询失败直接报404错误

ok,这个功能宣告完成,是不是很简单,发现这篇博文写的有点长了,但是最后还有一个地方要思考一下,就是用户的lastseen字段,在什么时候更新合适呢,最简单的方式当然是登录的时候进行更新,但这样真的好吗,想象一下,我在登录后如果进行频繁的操作,那么时间势必会不准确,所以最好的方法是在条件允许的情况下每次request的时候都进行更新,当然这样也不可避免的会消耗资源,如何取舍由自己来决定,下面这个例子中实现一下这个功能:

首先在用户模型中添加方法:

class User(UserMixin,db.Model):
...
def visit(self):
self.lastseen=datetime.utcnow()
db.session.add(self);

然后在试图控制器中:

@auth.before_app_request
def before_request():
if(current_user.is_authenticated):
current_user.visit()

添加这个方法即可。