目录
一、项目简介
二、项目功能
三、技术分析
1、为什么选择Flask?
2、为什么选择 Mariadb ?
3、为什么选择Bootstrap?
四、Flask开发大型项目结构
1、项目结构
2、配置文件选项
3、程序工厂函数
1、为什么需要程序工厂函数?
2、如何实现程序工厂函数?
4、蓝图: 组件化开发
5、启动脚本
6、依赖包文件
7、单元测试
1、什么是单元测试?
2、如何实现单元测试?
3、unittest 核心概念及关系
4、任务清单单元测试的应用
5、项目需求文档
6、用户认证
7、技术要点
8、核心代码
9、测试代码
五、Flask-Login优化数据库模型
1、技术要点
2、核心代码
3、数据库创建
2、用户登录业务逻辑
3、测试方式
六、用户邮箱验证
1、邮箱验证数据库模型
2、用户注册验证邮件的业务逻辑
3、电子邮件模板准备
4、电子邮件配置信息准备
5、发送电子邮件的业务逻辑
6、注册与确认验证的业务逻辑
七、基于Flask的任务清单管理系统(三): 用户资料
1、视图函数
2、前端页面
3、用户资料编辑
八、任务清单管理
1、数据库模型
2、表单文件
3、视图函数文件
4、分页展示
# config.py
"""
存储配置;
"""
import os
# 获取当前项目的绝对路径;
basedir = os.path.abspath(os.path.dirname(__file__))
class Config:
"""
所有配置环境的基类, 包含通用配置
"""
SECRET_KEY = os.environ.get('SECRET_KEY') or 'westos secret key' SQLALCHEMY_COMMIT_ON_TEARDOWN = True
SQLALCHEMY_TRACK_MODIFICATIONS = True FLASKY_MAIL_SUBJECT_PREFIX = '[西部开源]' FLASKY_MAIL_SENDER = '[email protected]'
@staticmethod
def init_app(app):
pass
class DevelopmentConfig(Config):
""" 开发环境的配置信息 """
# 启用了调试支持,服务器会在代码修改后自动重新载入,并在发生错误时提供一个相当有用的调试 器。
DEBUG = True
MAIL_SERVER = 'smtp.qq.com'
MAIL_PORT = 587
MAIL_USE_TLS = True
MAIL_USERNAME = os.environ.get('MAIL_USERNAME') or '976131979'
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') or '密码'
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'data- dev.sqlite') class TestingConfig(Config):
""" 测试环境的配置信息 """
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'data- test.sqlite')
class ProductionConfig(Config):
""" 生产环境的配置信息 """
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'data.sqlite')
config = {
'development': DevelopmentConfig,
'testing': TestingConfig,
'production': ProductionConfig,
'default': DevelopmentConfig
}
app = Flask(__name__)
bootstrap = Bootstrap(app)
mail = Mail(app)
bootstrap = Bootstrap()
mail = Mail()
def create_app():
app = Flask(__name__)
bootstrap.init_app(app)
mail.init_app(app)
return app
"""
程序工厂函数, 延迟创建程序实例
"""
from flask import Flask
from flask_bootstrap import Bootstrap
from flask_mail import Mail
from flask_sqlalchemy import SQLAlchemy
from config import config
bootstrap = Bootstrap()
mail = Mail()
db = SQLAlchemy()
def create_app(config_name='development'):
""" 默认创建开发环境的app对象 """
app = Flask(__name__)
app.config.from_object(config[config_name])
config[config_name].init_app(app)
bootstrap.init_app(app)
mail.init_app(app)
db.init_app(app)
# 附加路由和自定义的错误页面
# .........后续还需完善, 补充视图和错误页面
return app
# 'auth'是蓝图的名称
# __name__是蓝图所在路径
auth =Blueprint('auth',__name__)
from . import views
from flask import Blueprint
# 实例化一个 Blueprint 类对象可以创建蓝本, 指定蓝本的名字和蓝本所在的包或模块
auth = Blueprint('auth', __name__)
# 把路由和错误处理程序与蓝本关联, 一定要写在最后, 防止循环导入依赖;
from . import views, errors
from . import auth
@auth.route('/login')
def login():
return 'login'
@auth.route('/logout')
def logout():
return 'logout'
def create_app(config_name='development'):
"""
默认创建开发环境的app对象
"""
# ...... # 附加路由和自定义的错误页面
from app.auth import auth as auth_bp
app.register_blueprint(auth_bp) # 注册蓝本
from app.todo import todo as todo_bp
app.register_blueprint(todo_bp, ) # 注册蓝本
return app
"""
用于启动程序以及其他的程序任务。
"""
from flask_migrate import Migrate, MigrateCommand
from flask_script import Manager, Shell
from app import create_app, db
app = create_app('default')
manager = Manager(app)
migrate = Migrate(app, db)
def make_shell_context():
return dict(app=app, db=db)
# 初始化 Flask-Script、Flask-Migrate 和为 Python shell 定义的上下文。 manager.add_command("shell", Shell(make_context=make_shell_context)) manager.add_command('db', MigrateCommand)
if __name__ == '__main__':
manager.run()
# python3 manage.py runserver --help 获取详细使用参数
python3 manage.py runserver
程序中必须包含一个 requirements.txt 文件,用于记录所有依赖包及其精确的版本号。
pip freeze > requirements.txt
pip install -r requirements.txt
# tests/test_number.py i
mport random
import unittest
"""
单独执行测试用例: python3 -m unittest test_number.py
"""
class TestSequenceFunctions(unittest.TestCase):
"""
setUp() 和 tearDown() 方法分别在各测试前后运行,并且名字以 test_ 开头的函数都作为测试 执行。
"""
def setUp(self):
self.seq = list(range(10))
def test_shuffle(self):
# make sure the shuffled sequence does not lose any elements
random.shuffle(self.seq)
self.seq.sort()
self.assertEqual(self.seq, list(range(10)))
# should raise an exception for an immutable sequence
self.assertRaises(TypeError, random.shuffle, (1, 2, 3))
def test_choice(self):
element = random.choice(self.seq)
self.assertTrue(element in self.seq)
def test_sample(self):
with self.assertRaises(ValueError):
random.sample(self.seq, 20)
for element in random.sample(self.seq, 5):
self.assertTrue(element in self.seq)
def tearDown(self):
del self.seq
python3 -m unittest test_number.py
# tests/test_basics.py
import unittest
from flask import current_app
from app import create_app, db
class BasicsTestCase(unittest.TestCase):
"""
setUp() 和 tearDown() 方法分别在各测试前后运行,并且名字以 test_ 开头的函数都作为测试执 行。
"""
def setUp(self):
"""
在测试前创建一个测试环境。
1). 使用测试配置创建程序
2). 激活上下文, 确保能在测试中使用 current_app
3). 创建一个全新的数据库,以备不时之需。
:return:
"""
self.app = create_app('testing')
self.app_context = self.app.app_context()
# Binds the app context to the current context.
self.app_context.push()
db.create_all()
def tearDown(self):
db.session.remove()
db.drop_all()
# Pops the app context
self.app_context.pop()
def test_app_exists(self):
"""
测试当前app是否存在?
"""
self.assertFalse(current_app is None)
def test_app_is_testing(self):
"""
测试当前app是否为测试环境?
"""
self.assertTrue(current_app.config['TESTING'])
# manage.py
# manager.command 修饰器让自定义命令变得简单。修饰函数名就是命令名,函数的文档字符串会显示在帮 助消息中。
@manager.command
def test():
"""Run the unit tests."""
import unittest
tests = unittest.TestLoader().discover('tests')
unittest.TextTestRunner(verbosity=2).run(tests)
# 初始化项目为Git仓库
git init
Initialized empty Git repository in
/home/kiosk/Desktop/201905python/Todolist/.git/
# 将所有项目文件添加到暂存区
git add *
# 提交到本地仓库
git commit -m "Flask任务清单管理系统(一): 大型项目结构化管理"
# 添加一个新的远程仓库, 第一次需要, 后面的不需要添加.
git remote add origin https://github.com/lvah/TodoList.git
# 上传项目至远程仓库`
Github git push -u origin master
# app/models.py
from app import db
from werkzeug.security import generate_password_hash, check_password_hash
class Role(db.Model):
"""
用户类型
"""
__tablename__ = 'roles'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), unique=True)
users = db.relationship('User', backref='role')
def __repr__(self):
return '' % self.name
class User(db.Model):
"""用户"""
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, index=True)
password_hash = db.Column(db.String(128)) # 加密的密码
role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
@property
def password(self):
raise AttributeError('password is not a readable attribute')
@password.setter
def password(self, password):
# generate_password_hash(password, method= pbkdf2:sha1 , salt_length=8)
:密码加密的散列值。
self.password_hash = generate_password_hash(password)
def verify_password(self, password):
# check_password_hash(hash, password) :密码散列值和用户输入的密码是否匹配.
return check_password_hash(self.password_hash, password)
def __repr__(self):
return '' % self.username
# tests/test_user_model.py
import unittest
from app.models import User
class UserModelTestCase(unittest.TestCase):
""" 用户数据库模型测试 """
def test_password_setter(self):
"""测试新建的用户密码是否为空?"""
u = User(password='cat')
self.assertTrue(u.password_hash is not None)
def test_no_password_getter(self):
"""测试获取密码信息是否报错?"""
u = User(password='cat') with
self.assertRaises(AttributeError): password =u.password
def test_password_verification(self):
"""测试加密密码和明文密码是否验证正确?"""
u = User(password='cat')
self.assertTrue(u.verify_password('cat'))
self.assertFalse(u.verify_password('dog'))
def test_password_salts_are_random(self):
"""测试每次密码加密的加密字符是否不一致?"""
u = User(password='cat')
u2 = User(password='cat')
self.assertTrue(u.password_hash != u2.password_hash)
# app/__init__.py
from flask_login import LoginManager
# .......此处省略前面重复的代码
login_manager = LoginManager()
# session_protection 属性提供不同的安全等级防止用户会话遭篡改。 login_manager.session_protection = 'strong'
# login_view 属性设置登录页面的端点。
login_manager.login_view = 'auth.login'
def create_app(config_name='development'):
# .......
# 用户认证新加扩展
login_manager.init_app(app)
# ........
return app
# app/models.py
from flask_login import UserMixin
from . import login_manager
""" Flask-Login 提供了一个 UserMixin 类,包含常用方法的默认实现,且能满足大多数需求。
1). is_authenticated 用户是否已经登录?
2). is_active 是否允许用户登录?False代表用户禁用
3). is_anonymous 是否匿名用户?
4). get_id() 返回用户的唯一标识符
"""
class User(UserMixin, db.Model):
"""用户"""
# .............
# 电子邮件地址email,相对于用户名而言,用户更不容易忘记自己的电子邮件地址。
email = db.Column(db.String(64), unique=True, index=True)
# .............
# 加载用户的回调函数;如果能找到用户,返回用户对象;否则返回 None 。
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
# 初始化数据库, 创建migrations目录,存放了所有迁移脚本。只需要执行一次。
python3 manage.py db init
# 创建迁移脚本
python3 manage.py db migrate
# 更新数据库
python3 manage.py db upgrade
# app/auth/forms.py:用户注册表单
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Length, Email, Regexp, EqualTo, ValidationError
from app.models import User
class RegistrationForm(FlaskForm):
email = StringField('电子邮箱', validators=[
DataRequired(), Length(1, 64), Email()])
username = StringField('用户名', validators=[
DataRequired(), Length(1, 64),
Regexp('^\w*$', message='用户名只能由字母数字或者下划线组成')])
password = PasswordField('密码', validators=[
DataRequired(), EqualTo('repassword', message='密码不一致')])
repassword = PasswordField('确认密码', validators=[DataRequired()])
submit = SubmitField('注册')
# 两个自定义的验证函数, 以validate_ 开头且跟着字段名的方法,这个方法和常规的验证函数一起调 用。
def validate_email(self, field):
if User.query.filter_by(email=field.data).first():
# 自定义的验证函数要想表示验证失败,可以抛出 ValidationError 异常,其参数就是错 误消息。
raise ValidationError('电子邮箱已经注册.')
def validate_username(self, field):
if User.query.filter_by(username=field.data).first():
raise ValidationError('用户名已经占用.')
# app/auth/views.py:用户注册路由
from flask import request, redirect, url_for, flash, render_template
from flask_login import login_user, login_required, logout_user
from app import db
from app.auth.forms import RegistrationForm
from app.models import User
from . import auth
# .........
@auth.route('/register', methods=['GET', 'POST'])
def register():
form = RegistrationForm()
if form.validate_on_submit():
user = User() user.email = form.email.data
user.username = form.username.data
user.password = form.password.data
db.session.add(user) flash('注册成功, 请登录')
return redirect(url_for('auth.login'))
return render_template('auth/register.html', form=form)
# ...........
# templates/auth/login.html
{% extends 'bootstrap/base.html' %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block navbar %}
用户注册
{{ wtf.quick_form(form) }}
{% endblock %}
{% endblock %}
{% block content %}
# app/auth/forms.py:用户登录表单
class LoginForm(FlaskForm):
"""用户登录表单"""
email = StringField('电子邮箱', validators=[DataRequired(), Length(1, 64),
Email()])
password = PasswordField('密码', validators=[DataRequired()])
submit = SubmitField('登录')
# app/auth/views.py
# ...........
@auth.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user is not None and user.verify_password(form.password.data):
# 调用 Flask-Login 中的 login_user() 函数,在用户会话中把用户标记为已登录。
# login_user() 函数的参数是要登录的用户,以及可选的“记住我”布尔值,“记住我”也在 表单中填写。
login_user(user)
return redirect(request.args.get('next') or url_for('auth.login')) flash('无效的用户名和密码.')
return render_template('auth/login.html', form=form)
# ........
# templates/auth/login.html
{% extends 'bootstrap/base.html' %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block navbar %}
用户登录
{{ wtf.quick_form(form) }}
{% endblock %}
{% endblock %}
{% block content %}
# app/auth/views.py
@auth.route('/logout')
@login_required
def logout():
logout_user()
flash('用户注销成功.')
return redirect(url_for('auth.login'))
git add *
git commit -m "基于Flask的任务清单管理系统(二): 用户认证基本功能实现"
git push origin master
# app/models.py
class User(UserMixin, db.Model):
# .................
confirmed = db.Column(db.Boolean, default=False)
def generate_confirmation_token(self, expiration=3600):
"""生成一个令牌,有效期默认为一小时。"""
s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY'], expiration)
return s.dumps({'confirm': self.id})
def confirm(self, token):
""" 检验令牌和检查令牌中id和已登录用户id是否匹配?如果检验通过,则把新添加的 confirmed 属 性设为 True
"""
s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY'])
try:
data = s.loads(token)
except:
return False
if data.get('confirm') != self.id:
return False
self.confirmed = True
db.session.add(self)
db.session.commit()
return True
# 对数据库(db)进行迁移(migrate)。-m选项用来添加迁移备注信息。
python3 manage.py db migrate -m "添加账户的确认状态"
# 生成了迁移脚本后,使用upgrade子命令即可更新数据库.
python3 manage.py db upgrade
from flask import flash, url_for, redirect, render_template, request,
current_app
from flask_login import login_required, current_user
from app import db
from app.models import Todo, Category
from app.todo.forms import AddTodoForm, EditTodoForm, AddCategoryForm
from . import todo
@todo.route('/add/', methods=['GET', 'POST'])
@login_required
def add():
form = AddTodoForm()
if form.validate_on_submit():
# 获取用户提交的内容
content = form.content.data
category_id = form.category.data
# 添加到数据库中
todo = Todo(content=content, category_id=category_id, user_id=current_user.id )
db.session.add(todo)
flash('添加任务成功', category='success')
return redirect(url_for('todo.add'))
print(Category.query.all())
return render_template('todo/add.html', form=form)
# 编辑任务
@todo.route('/edit//', methods=['GET', 'POST'])
def edit(id):
form = EditTodoForm()
# *****重要: 编辑时需要获取原先任务的信息, 并显示到表单里面;
todo = Todo.query.filter_by(id=id).first()
form.content.data = todo.content
form.category.data = todo.category_id
if form.validate_on_submit():
# 更新时获取表单数据一定要使用request.form方法获取,
# 而form.content.data并不能获取用户更新后提交的表单内容;
content = request.form.get('content')
category_id = request.form.get('category')
# 更新到数据库里面
todo.content = content
todo.category_id = category_id
db.session.add(todo)
flash('任务已更新', category='success')
return redirect(url_for('todo.list'))
return render_template('todo/edit.html', form=form)
# 删除任务: 根据任务id删除
@todo.route('/delete//')
@login_required
def delete(id):
todo = Todo.query.filter_by(id=id).first()
db.session.delete(todo)
flash("删除任务成功", category='success')
return redirect(url_for('todo.list'))
# 查看任务
@todo.route('/list/')
@login_required
def list(page=1):
# 任务显示需要分页,每个用户只能查看自己的任务
todoPageObj = Todo.query.filter_by(
user_id=current_user.id).paginate(
# 在config.py文件中有设置;
page, per_page=current_app.config['PER_PAGE'])
return render_template('todo/list.html', todoPageObj=todoPageObj)
# 修改任务状态为完成
@todo.route('/done//')
@login_required
def done(id):
todo = Todo.query.filter_by(id=id).first()
todo.status = True db.session.add(todo) flash('修改状态成功')
return redirect(url_for('list'))
# 修改任务状态为未完成
@todo.route('/undo/')
@login_required
def undo(id):
todo = Todo.query.filter_by(id=id).first()
todo.status = False
db.session.add(todo) flash("修改状态成功")
return redirect(url_for('list'))
@todo.route('/category/add/', methods=['GET', 'POST'])
@login_required
def category_add():
form = AddCategoryForm()
if form.validate_on_submit():
# 获取用户提交的内容
content = form.content.data
# 添加到数据库中
category = Category(name=content,
user_id=current_user.id )
db.session.add(category)
flash('添加分类成功', category='success')
return redirect(url_for('todo.category_add'))
print(Category.query.all())
return render_template('todo/category_add.html', form=form)
# 查看任务
@todo.route('/category/list/')
@login_required
def category_list(page=1):
print(page)
# 任务显示需要分页,每个用户只能查看自己的任务
categoryPageObj = Category.query.filter_by(
user_id=current_user.id).paginate(
# 在config.py文件中有设置;
page, per_page=current_app.config['PER_PAGE'])
return render_template('todo/category_list.html',
categoryPageObj=categoryPageObj)
@app.route('/')
def index():
page = int(request.args.get('page',1))
paginate = Students.query.paginate(page,2)
stus = paginate.items
return render_template('index.html','stus'=stus,'paginate'=paginate)
{% if paginate.has_prev %}
上一页
{% endif %}
{% for i in paginate.iter_pages() %}
{% if not i %}
...
{% else %}
{{ i }}
{% endif %}
{% endfor %}
{% if paginate.has_next %}
下一页
{% endif %}