Для хоть сколько-то опытных программистов она вряд ли будет полезна, многие могут вовсе не согласиться с описанными принципами, однако для находящихся на ранней стадии обучения она может стать толчком к развитию, как стала для меня. Именно поэтому я сделал перевод на русский язык — у этой статьи очень низкий порог вхождения и стоит сделать его еще ниже.
Описанный пример протестирован на Python 3.5, Flask 0.10, Flask-SQLAlchemy 2.1, Flask-WTF 0.9.
Проектирование простых приложений в Flask
Этот документ не входит в официальную документацию Flask. Он является компиляцией советов, полученных из различных неофициальных источников и никогда не подвергался какой-либо проверке. Описанные методики могут быть весьма полезны, но в то же время и достаточно опасны. Просьба не вносить никаких изменений в оригинальный документ, размещенный на Github, так как на него ссылаются многие ответы на StackOverflow. Вы можете вносить в него любые поправки и заметки, но для размещения используйте личный сайт или блог.
Данная статья является попыткой описать структуру проекта, использующего Flask и базовые модули SQLAlchemy и WTForms.
Установка
Flask
Инструкция по установке Flask.
Я рекомендую использовать virtualenv — эта система очень проста и позволяет размещать несколько виртуальных окружений на одной системе и не требует прав суперпользователя, так как все библиотеки устанавливаются локально.
Flask-SQLAlchemy
SQLAlchemy обеспечивает простой и мощный интерфейс взаимодействия ваших объектов и реляционной базы данных любого типа. Для установки Flask-SQLAlchemy в ваше виртуальное окружение используйте pip:
pip install flask-sqlalchemy
Более полное описание пакета Flask-SQLAlchemy.
Flask-WTF
WTForms упрощает получение данных от пользователя.
pip install Flask-WTF
Более полное описание пакета Flask-WTF.
Введение
Итак, необходимые библиотеки подготовлены. Так должна выглядеть основная структура вашего проекта:
/app/users/__init__.py /app/users/views.py /app/users/forms.py /app/users/constants.py /app/users/models.py /app/users/decorators.py
Для каждего модуля (элемента приложения) создаётся следующая структура:
/app/templates/404.html /app/templates/base.html /app/templates/users/login.html /app/templates/users/register.html ...
Шаблоны представления (jinja) хранятся в директории templates и поддиректория модулей:
/app/static/js/main.js /app/static/css/reset.css /app/static/img/header.png
Для обработки неизменяемых файлов необходимо использовать отдельный веб-сервер, однако на время разработки можно возложить эту работу на Flask. Он автоматически выдает такие файлы из директории static, а для настройки использования другой директории вы можете воспользоваться информацией из данной статьи.
Для рассматриваемого приложения будут создан один модуль: users. Он обеспечит управление регистрацией и входом пользователей, просмотр данных своего профайла.
Конфигурация
/run.py используется для запуска веб-сервера:
from app import app app.run(debug=True)
/shell.py даст доступ к консоли с возможностью выполнения команд. Возможно, не так удобно, как отладка через pdb, но достаточно полезно (по крайней мере при инициализации базы данных):
#!/usr/bin/env python import os import readline from pprint import pprint from flask import * from app import * os.environ['PYTHONINSPECT'] = 'True'
Примечание переводчика:
В случае, если вы работаете в ОС Windows (не надо бросать кирпичи!), библиотека readline недоступна. В таком случае необходимо установить в своё виртуальное или реальное окружение python библиотеку pyreadline и обернуть импорт в конструкцию вида:
try: import readline except: import pyreadline
В принципе, можно и вовсе обойтись без этой библиотеки, она просто упрощает взаимодействие с консолью, добавляя в нее некоторые bash-like элементы.
/config.py хранит всю конфигурацию приложения. В данном примере в качестве базы данных используется SQLite, так как она очень удобна при разработке. Скорее всего файл /config.py не стоит включать в репозиторий, так как он будет разным на тестовой и промышленной системах.
import os _basedir = os.path.abspath(os.path.dirname(__file__)) DEBUG = False ADMINS = frozenset(['youremail@yourdomain.com']) SECRET_KEY = 'This string will be replaced with a proper key in production.' SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(_basedir, 'app.db') DATABASE_CONNECT_OPTIONS = {} THREADS_PER_PAGE = 8 WTF_CSRF_ENABLED = True WTF_CSRF_SECRET_KEY = "somethingimpossibletoguess" RECAPTCHA_USE_SSL = False RECAPTCHA_PUBLIC_KEY = '6LeYIbsSAAAAACRPIllxA7wvXjIE411PfdB2gt2J' RECAPTCHA_PRIVATE_KEY = '6LeYIbsSAAAAAJezaIq3Ft_hSTo0YtyeFG-JgRtu' RECAPTCHA_OPTIONS = {'theme': 'white'}
- _basedir — переменная, в которую помещается исполняемая директория скрипта;
- DEBUG определяет появление сообщений об ошибках в тестовом окружении;
- SECRET_KEY используется для подписи cookies, при его изменении пользователям потребуется логиниться заново;
- ADMINS содержит адрес электронной почты администраторов для рассылок из приложения;
- SQLALCHEMY_DATABASE_URI и DATABASE_CONNECT_OPTIONS, как несложно догадаться — опции подключения SQLAlchemy;
- THREADS_PER_PAGE, как мне кажется, ставил 2 на ядро… Могу ошибаться;
- WTF_CSRF_ENABLED и WTF_CSRF_SECRET_KEY защищают от подмены POST-сообщений;
- RECAPTCHA_* используется для входящего в WTForms поля RecaptchaField. Получить приватный и публичный ключи можно на сайте **recaptcha.
Модуль
Настроим модуль users в следующем порядке: определим модели, связанные с моделями константы, далее форму и, наконец, представление и шаблоны.
Модель
/app/users/models.py:
from app import db from app.users import constants as USER class User(db.Model): __tablename__ = 'users_user' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(50), unique=True) email = db.Column(db.String(120), unique=True) password = db.Column(db.String(120)) role = db.Column(db.SmallInteger, default=USER.USER) status = db.Column(db.SmallInteger, default=USER.NEW) def __init__(self, name=None, email=None, password=None): self.name = name self.email = email self.password = password def getStatus(self): return USER.STATUS[self.status] def getRole(self): return USER.ROLE[self.role] def __repr__(self): return '<User %r>' % (self.name)
И её константы в файле /app/users/constants.py:
# User role ADMIN = 0 STAFF = 1 USER = 2 ROLE = { ADMIN: 'admin', STAFF: 'staff', USER: 'user', } # user status INACTIVE = 0 NEW = 1 ACTIVE = 2 STATUS = { INACTIVE: 'inactive', NEW: 'new', ACTIVE: 'active', }
К слову о константах: мне нравится, когда константы хранятся в отдельном файле внутри модуля. Константы скорее всего будут использоваться в моделях, формах и представлениях, так что таким образом вы получите удобно организованные данные, которые будет просто найти. К тому же, импортирование констант под именем модуля в верхнем регистре (например USERS для users.constants) поможет избежать конфликтов имен.
Форма
Когда создана модель нужного объекта, необходимо сконструировать форму для работы с ней.
Форма регистрации будет запрашивать имя пользователя, адрес электронной почты и пароль, будут использованы валидаторы для проверки корректности введенных пользователем данных, а поле Recaptcha защитит от регистрации ботов. На случай, если понадобится внедрить пользовательское соглашение, также добавлено поле BooleanField с именем accept_tos. Данное поле помечено, как required, то есть пользователь будет обязан отметить генерируемый формой чекбокс. Форма входа снабжена полями email и password с аналогичными валидаторами.
Описание форм содержится в файле /app/users/forms.py:
from flask.ext.wtf import Form, RecaptchaField from wtforms import TextField, PasswordField, BooleanField from wtforms.validators import Required, EqualTo, Email class LoginForm(Form): email = TextField('Email address', [Required(), Email()]) password = PasswordField('Password', [Required()]) class RegisterForm(Form): name = TextField('NickName', [Required()]) email = TextField('Email address', [Required(), Email()]) password = PasswordField('Password', [Required()]) confirm = PasswordField('Repeat Password', [ Required(), EqualTo('password', message='Passwords must match') ]) accept_tos = BooleanField('I accept the TOS', [Required()]) recaptcha = RecaptchaField()
Первый параметр для каждого поля — его метка, например для поля name в форме задана метка NickName. Для полей ввода пароля используется валидатор EqualTo, сравнивающий данные в двух полях.
Более полная информация о возможностях WTForms находится по этой ссылке.
Представление
В представлении объявляется Blueprint — объект схемы модуля, в свойствах которого указывается url_prefix, который будет подставляться в начале любого URLа, указанного в route. Также в представлении используется метод формы form.validate_on_submit, выдающий истину для метода HTTP POST и валидной формы. После успешного входа пользователь перенаправляется на страницу профиля (/users/me). Для предотвращения доступа неавторизованных пользователей создаётся специальный декоратор в файле /app/users/decorators.py:
from functools import wraps from flask import g, flash, redirect, url_for, request def requires_login(f): @wraps(f) def decorated_function(*args, **kwargs): if g.user is None: flash(u'You need to be signed in for this page.') return redirect(url_for('users.login', next=request.path)) return f(*args, **kwargs) return decorated_function
Данный декоратор проверяет наличие данных в переменной g.user. В случае, если переменная не задана, то пользователь не аутенфицирован, тогда задаётся информационное сообщение и осуществляется перенаправление на представление login (вход в систему). Данные в переменную g.user помещаются в функции before_request. Когда вы получаете большой объем данных из профиля пользователя (исторические данные, друзья, сообщения, действия) возможно серьезное замедление работы при обращении к БД, так что кеширование данных пользователей может решить эту проблему (но только пока вы модифицируете объекты централизованно и очищаете кеш при каждом обновлении). Ниже прилагается код представления /app/users/views.py:
from flask import Blueprint, request, render_template, flash, g, session, redirect, url_for from werkzeug import check_password_hash, generate_password_hash from app import db from app.users.forms import RegisterForm, LoginForm from app.users.models import User from app.users.decorators import requires_login mod = Blueprint('users', __name__, url_prefix='/users') @mod.route('/me/') @requires_login def home(): return render_template("users/profile.html", user=g.user) @mod.before_request def before_request(): """ pull user's profile from the database before every request are treated """ g.user = None if 'user_id' in session: g.user = User.query.get(session['user_id']) @mod.route('/login/', methods=['GET', 'POST']) def login(): """ Login form """ form = LoginForm(request.form) # make sure data are valid, but doesn't validate password is right if form.validate_on_submit(): user = User.query.filter_by(email=form.email.data).first() # we use werzeug to validate user's password if user and check_password_hash(user.password, form.password.data): # the session can't be modified as it's signed, # it's a safe place to store the user id session['user_id'] = user.id flash('Welcome %s' % user.name) return redirect(url_for('users.home')) flash('Wrong email or password', 'error-message') return render_template("users/login.html", form=form) @mod.route('/register/', methods=['GET', 'POST']) def register(): """ Registration Form """ form = RegisterForm(request.form) if form.validate_on_submit(): # create an user instance not yet stored in the database user = User(name=form.name.data, email=form.email.data, \ password=generate_password_hash(form.password.data)) # Insert the record in our database and commit it db.session.add(user) db.session.commit() # Log the user in, as he now has an id session['user_id'] = user.id # flash will display a message to the user flash('Thanks for registering') # redirect user to the 'home' method of the user module. return redirect(url_for('users.home')) return render_template("users/register.html", form=form)
Шаблон
Шаблонизатор Jinja встроен в Flask. Одним из его преимуществ является возможность наследования и встроенной логики (зависимости, циклы, контекстные изменения). Создадим шаблон /app/templates/base.html, от которого будут наследоваться остальные шаблоны. Возможно задание более чем одного наследования (например наследование от шаблона twocolumn.html, который в свою очередь наслудется от main.html). Базовый шаблон также упрощает отображение информационных (flash) сообщений из переменной get_flashed_messages в каждом наследующем шаблоне.
Теперь нет необходимости задавать основную структуру страницы и каждое изменение base.html отразится на наследующих шаблонах. Рекомендуется называть шаблоны в соответствии с вызывающими их представлениями, именно так поименован шаблон /app/templates/users/register.html:
<html> <head> <title>{% block title %}My Site{% endblock %}</title> {% block css %} <link rel="stylesheet" href="/static/css/reset-min.css" /> <link rel="stylesheet" href="/static/css/main.css" /> {% endblock %} {% block script %} <script src="/static/js/main.js" type="text/javascript"></script> {% endblock %} </head> <body> <div id="header">{% block header %}{% endblock %}</div> <div id="messages-wrap"> <div id="messages"> {% for category, msg in get_flashed_messages(with_categories=true) %} <p class="message flash-{{ category }}">{{ msg }}</p> {% endfor %} </div> </div> <div id="content">{% block content %}{% endblock %}</div> <div id="footer">{% block footer %}{% endblock %}</div> </body> </html>
И шаблон /app/templates/users/login.html:
{% extends "base.html" %} {% block content %} {% from "forms/macros.html" import render_field %} <form method="POST" action="." class="form"> {{ form.csrf_token }} {{ render_field(form.email, class="input text") }} {{ render_field(form.password, class="input text") }} <input type="submit" value="Login" class="button green"> </form> <a href="{{ url_for('users.register') }}">Register</a> {% endblock %}
Созданные шаблоны используют макросы для автоматизации создания полей html. Так как этот макрос будет использоваться в различных модулях, он помещен в отдельный файл /app/templates/forms/macros.html:
{% macro render_field(field) %} <div class="form_field"> {{ field.label(class="label") }} {% if field.errors %} {% set css_class = 'has_error ' + kwargs.pop('class', '') %} {{ field(class=css_class, **kwargs) }} <ul class="errors">{% for error in field.errors %}<li>{{ error|e }}</li>{% endfor %}</ul> {% else %} {{ field(**kwargs) }} {% endif %} </div> {% endmacro %}
Наконец, создан примитивный шаблон /app/templates/users/profile.html:
{% extends "base.html" %} {% block content %} Hi {{ user.name }}! {% endblock %}
Инициализация приложения
Как несложно догадаться, инициализация приложения происходит в файле /app/init.py:
import os import sys from flask import Flask, render_template from flask.ext.sqlalchemy import SQLAlchemy app = Flask(__name__) app.config.from_object('config') db = SQLAlchemy(app) ######################## # Configure Secret Key # ######################## def install_secret_key(app, filename='secret_key'): """Configure the SECRET_KEY from a file in the instance directory. If the file does not exist, print instructions to create it from a shell with a random key, then exit. """ filename = os.path.join(app.instance_path, filename) try: app.config['SECRET_KEY'] = open(filename, 'rb').read() except IOError: print('Error: No secret key. Create it with:') full_path = os.path.dirname(filename) if not os.path.isdir(full_path): print('mkdir -p {filename}'.format(filename=full_path)) print('head -c 24 /dev/urandom > {filename}'.format(filename=filename)) sys.exit(1) if not app.config['DEBUG']: install_secret_key(app) @app.errorhandler(404) def not_found(error): return render_template('404.html'), 404 from app.users.views import mod as usersModule app.register_blueprint(usersModule) # Later on you'll import the other blueprints the same way: #from app.comments.views import mod as commentsModule #from app.posts.views import mod as postsModule #app.register_blueprint(commentsModule) #app.register_blueprint(postsModule)
Экземпляр БД SQLAlchemy и модель Users находятся в двух разных файлах, необходимо импортировать оба из них в общее пространство имен с помощью строки from app.users.views import mod as usersModule. В противном случае команда db.create_all() не принесет результата.
Активируем виртуальное окружение virtualenv и инициализируем БД:
user@Machine:~/Projects/dev$ . env/bin/activate (env)user@Machine:~/Projects/dev$ python shell.py >>> from app import db >>> db.create_all() >>> exit()
Теперь можно выполнить команду python run.py и получить сообщение следующего вида:
(env)user@Machine:~/Projects/dev$ python run.py * Running on http://127.0.0.1:5000/ * Restarting with reloader
Открыв в браузере адрес http://127.0.0.1:500/users/me/ вы будете перенаправлены на страницу входа и увидите ссылку на страницу регистрации.