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

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

这是一个很好的场景,不是么,下面我们就要来实现它,首先来说,存储一张图片有多重方法,服务器本地存储,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 点击新建存储空间如图示:

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,显示:

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

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()

添加这个方法即可。

posted @ 2017-03-27 23:18  双鱼座的牛  阅读(703)  评论(5编辑  收藏  举报