Selfish, база данных приложения и авторизация пользователей

avm

Опубликован:  2018-03-12T13:40:11.469274Z
Отредактирован:  2018-03-12T13:38:51.907735Z
1000
Представлено пошаговое описание процесса создания базы данных web-приложения на базе PostgreSQL и SQLAlchemy, а так же создания системы авторизации пользователей приложения. В результате выполнения описанных здесь действий Selfish получит базу данных, пользователя и его аккаунт и возможность входа в сервис для зарегистрированных пользователей.

1. В предыдущих сериях

Selfish - web-приложение на базе Python3 и Flask. Начальная конфигурация Selfish детально описана в порядке следования в следующих топиках:

Все топики посвященные Selfish можно отфильтровать по метке selfish. На текущий момент Selfish имеет оформленную главную страницу с пользовательским меню, менеджера с возможностью подгружать служебную консоль и запускать отладочный сервер.

В этом описании будет начата разработка системы авторизации пользователей, будет разработана страница входа в сервис. В приложении появится база данных и первый пользователь.

2. Организация рабочего места

Несколько слов об организации рабочего места, на котором я выполняю все описываемые действия. Поскольку Selfish будет иметь базу данных, мне потребуется базовый сервер PostgreSQL. Устанавливать серверное ПО на свой десктоп мне не очень хочется, поэтому я создал базовый сервер в VirtualBox, установил на него необходимое программное обеспечение и настроил таким образом, что к серверу PostgreSQL можно подключаться из консоли моего десктопа.

sadmin@debian:~$ psql -h 192.168.56.102 -U sadmin -d startpoint
Пароль пользователя sadmin: 
psql (9.6.6)
SSL-соединение (протокол: TLSv1.2, шифр: ECDHE-RSA-AES256-GCM-SHA384, бит: 256, сжатие: выкл.)
Введите "help", чтобы получить справку.

startpoint=> \q
sadmin@debian:~$ 

Дальнейшее описание предполагает, что базовый сервер запущен. Детальную информацию о настройке сервера можно почерпнуть в Debian stretch, подготовка рабочего места к web-разработке на Python3.

Чтобы продолжить разработку, вхожу в базовый каталог Selfish.

sadmin@debian:~$ cd ~/workspace/selfish
sadmin@debian:~/workspace/selfish$ 

Активирую виртуальное окружение.

sadmin@debian:~/workspace/selfish$ source venv/bin/activate
(venv) sadmin@debian:~/workspace/selfish$ 

Все дальнейшие действия в консоли буду выполнять находясь в этом каталоге.

3. Страница входа в сервис

Selfish в перспективе будет способна регистрировать новых пользователей, различать зарегистрированных пользователей и предоставлять им различные возможности, для этого приложению необходима процедура входа пользователя в сервис. Она подразумевает ввод пользователем логина и пароля, проверку системой их действительности, и в случае, когда логин и пароль действительны - авторизацию пользователя. Процедура входа пользователя в сервис будет осуществляться на служебной странице, которая сейчас будет создана в начальном виде.

Так как Selfish организована как сложное расширяемое web-приложение, для системы авторизации предусмотрена собственная подпрограмма - auth. Создаю каталог для этой подпрограммы.

(venv) sadmin@debian:~/workspace/selfish$ mkdir selfish/auth
(venv) sadmin@debian:~/workspace/selfish$ 

Создаю в этом каталоге три файла: __init__.py, views.py и forms.py.

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/auth/__init__.py
(venv) sadmin@debian:~/workspace/selfish$ touch selfish/auth/views.py
(venv) sadmin@debian:~/workspace/selfish$ touch selfish/auth/forms.py
(venv) sadmin@debian:~/workspace/selfish$ 

Открываю в редакторе PyCharm файл selfish/auth/__init__.py и создаю в нём новый экземпляр класса Blueprint.

from flask import Blueprint

auth = Blueprint('auth', __name__)

from . import views

Открываю в редакторе файл selfish/__init__.py и инициирую созданный экземпляр класса Blueprint в теле функции create_app.

    from .auth import auth as auth_blueprint
    app.register_blueprint(auth_blueprint, url_prefix='/auth')

Таким образом функция create_app теперь выглядит так.

def create_app(config_name):
    app = Flask(__name__)
    conf_obj = config[config_name]
    app.config.from_object(conf_obj)
    conf_obj.init_app(app)
    assets.init_app(app)
    from .main import main as main_blueprint
    app.register_blueprint(main_blueprint)
    from .auth import auth as auth_blueprint                         # новая строка
    app.register_blueprint(auth_blueprint, url_prefix='/auth')       # новая строка
    return app

Открываю файл selfish/auth/views.py и пишу в него следующий код.

from . import auth


@auth.route('/login')
def login():
    return 'This page is not ready yet.'

На текущий момент этого кода достаточно. Конечная точка для первой служебной страницы создана. Запускаю отладочный сервер и стучусь по созданному url-адресу в web-браузере.

auth-login

Свой окончательный вид и возможности, соответствующие замыслу, эта страница приобретёт чуть позже, а пока я займусь подготовкой необходимого для этой страницы функционала.

4. Установка дополнительных пакетов

На этой стадии разработки мне потребуются следующие дополнительные библиотеки для реализации поставленных задач:

  • psycopg2 - python-адаптер для PostgreSQL;
  • Flask-SQLAlchemy - расширение Flask для работы с SQLAlchemy;
  • Flask-Migrate - расширение Flask для миграции базы данных;
  • Flask-WTF - расширение Flask позволяющее встраивать в приложение формы;
  • Flask-Login - расширение Flask на базе которого будет построена авторизация пользователей;
  • validate-email - обеспечит простую валидацию e-mail адресов введённых в форму.

Адаптер psycopg2 можно установить двумя способами. Детально процесс описан в Debian stretch и Python3, установка psycopg2 в виртуальное окружение. Устанавливаю.

(venv) sadmin@debian:~/workspace/selfish$ pip install --no-binary :all: psycopg2
Collecting psycopg2
  Downloading psycopg2-2.7.4.tar.gz (425kB)
    100% |████████████████████████████████| 430kB 1.2MB/s 
Skipping bdist_wheel for psycopg2, due to binaries being disabled for it.
Installing collected packages: psycopg2
  Running setup.py install for psycopg2 ... done
Successfully installed psycopg2-2.7.4
(venv) sadmin@debian:~/workspace/selfish$ 

Поскольку данный пакет установлен нестандартным образом, это необходимо отразить в файле requirements.txt, который ещё много раз будет переписан. Поэтому делаю следующее. Сохраняю изменения в requirements.txt.

(venv) sadmin@debian:~/workspace/selfish$ pip freeze > requirements.txt 
(venv) sadmin@debian:~/workspace/selfish$ 

Создаю новый файл requirements-first.txt.

(venv) sadmin@debian:~/workspace/selfish$ grep psycopg2 requirements.txt > requirements-first.txt
(venv) sadmin@debian:~/workspace/selfish$ 

Теперь открываю файл requirements-first.txt в редакторе и дописываю в конец единственной строки следующее.

psycopg2-requirements

Устанавливаю остальные пакеты. Алхимия.

(venv) sadmin@debian:~/workspace/selfish$ pip install Flask-SQLAlchemy
Collecting Flask-SQLAlchemy
  Downloading Flask_SQLAlchemy-2.3.2-py2.py3-none-any.whl
Collecting SQLAlchemy>=0.8.0 (from Flask-SQLAlchemy)
  Downloading SQLAlchemy-1.2.5.tar.gz (5.6MB)
    100% |████████████████████████████████| 5.6MB 150kB/s 
Requirement already satisfied: Flask>=0.10 in ./venv/lib/python3.5/site-packages (from Flask-SQLAlchemy)
Requirement already satisfied: Jinja2>=2.4 in ./venv/lib/python3.5/site-packages (from Flask>=0.10->Flask-SQLAlchemy)
Requirement already satisfied: click>=2.0 in ./venv/lib/python3.5/site-packages (from Flask>=0.10->Flask-SQLAlchemy)
Requirement already satisfied: Werkzeug>=0.7 in ./venv/lib/python3.5/site-packages (from Flask>=0.10->Flask-SQLAlchemy)
Requirement already satisfied: itsdangerous>=0.21 in ./venv/lib/python3.5/site-packages (from Flask>=0.10->Flask-SQLAlchemy)
Requirement already satisfied: MarkupSafe>=0.23 in ./venv/lib/python3.5/site-packages (from Jinja2>=2.4->Flask>=0.10->Flask-SQLAlchemy)
Building wheels for collected packages: SQLAlchemy
  Running setup.py bdist_wheel for SQLAlchemy ... done
  Stored in directory: /home/sadmin/.cache/pip/wheels/61/22/7c/7f7a00946677df88d7072eb5c225108fc5c1e7e68b56a72689
Successfully built SQLAlchemy
Installing collected packages: SQLAlchemy, Flask-SQLAlchemy
Successfully installed Flask-SQLAlchemy-2.3.2 SQLAlchemy-1.2.5
(venv) sadmin@debian:~/workspace/selfish$ 

Миграции.

(venv) sadmin@debian:~/workspace/selfish$ pip install Flask-Migrate
Collecting Flask-Migrate
  Downloading Flask_Migrate-2.1.1-py2.py3-none-any.whl
Collecting alembic>=0.6 (from Flask-Migrate)
  Downloading alembic-0.9.8.tar.gz (1.0MB)
    100% |████████████████████████████████| 1.0MB 755kB/s 
Requirement already satisfied: Flask-SQLAlchemy>=1.0 in ./venv/lib/python3.5/site-packages (from Flask-Migrate)
Requirement already satisfied: Flask>=0.9 in ./venv/lib/python3.5/site-packages (from Flask-Migrate)
Requirement already satisfied: SQLAlchemy>=0.7.6 in ./venv/lib/python3.5/site-packages (from alembic>=0.6->Flask-Migrate)
Collecting Mako (from alembic>=0.6->Flask-Migrate)
  Downloading Mako-1.0.7.tar.gz (564kB)
    100% |████████████████████████████████| 573kB 1.2MB/s 
Collecting python-editor>=0.3 (from alembic>=0.6->Flask-Migrate)
  Downloading python-editor-1.0.3.tar.gz
Collecting python-dateutil (from alembic>=0.6->Flask-Migrate)
  Downloading python_dateutil-2.6.1-py2.py3-none-any.whl (194kB)
    100% |████████████████████████████████| 194kB 2.4MB/s 
Requirement already satisfied: Jinja2>=2.4 in ./venv/lib/python3.5/site-packages (from Flask>=0.9->Flask-Migrate)
Requirement already satisfied: Werkzeug>=0.7 in ./venv/lib/python3.5/site-packages (from Flask>=0.9->Flask-Migrate)
Requirement already satisfied: click>=2.0 in ./venv/lib/python3.5/site-packages (from Flask>=0.9->Flask-Migrate)
Requirement already satisfied: itsdangerous>=0.21 in ./venv/lib/python3.5/site-packages (from Flask>=0.9->Flask-Migrate)
Requirement already satisfied: MarkupSafe>=0.9.2 in ./venv/lib/python3.5/site-packages (from Mako->alembic>=0.6->Flask-Migrate)
Collecting six>=1.5 (from python-dateutil->alembic>=0.6->Flask-Migrate)
  Downloading six-1.11.0-py2.py3-none-any.whl
Building wheels for collected packages: alembic, Mako, python-editor
  Running setup.py bdist_wheel for alembic ... done
  Stored in directory: /home/sadmin/.cache/pip/wheels/29/f4/c3/96037a3f2dcc2b8b59eff64746ea71bb5957f189c5a0877364
  Running setup.py bdist_wheel for Mako ... done
  Stored in directory: /home/sadmin/.cache/pip/wheels/33/bf/8f/036f36c35e0e3c63a4685e306bce6b00b6349fec5b0947586e
  Running setup.py bdist_wheel for python-editor ... done
  Stored in directory: /home/sadmin/.cache/pip/wheels/84/d6/b8/082dc3b5cd7763f17f5500a193b6b248102217cbaa3f0a24ca
Successfully built alembic Mako python-editor
Installing collected packages: Mako, python-editor, six, python-dateutil, alembic, Flask-Migrate
Successfully installed Flask-Migrate-2.1.1 Mako-1.0.7 alembic-0.9.8 python-dateutil-2.6.1 python-editor-1.0.3 six-1.11.0
(venv) sadmin@debian:~/workspace/selfish$ 

Формы.

(venv) sadmin@debian:~/workspace/selfish$ pip install Flask-WTF
Collecting Flask-WTF
  Downloading Flask_WTF-0.14.2-py2.py3-none-any.whl
Collecting WTForms (from Flask-WTF)
  Downloading WTForms-2.1.zip (553kB)
    100% |████████████████████████████████| 563kB 1.1MB/s 
Requirement already satisfied: Flask in ./venv/lib/python3.5/site-packages (from Flask-WTF)
Requirement already satisfied: click>=2.0 in ./venv/lib/python3.5/site-packages (from Flask->Flask-WTF)
Requirement already satisfied: itsdangerous>=0.21 in ./venv/lib/python3.5/site-packages (from Flask->Flask-WTF)
Requirement already satisfied: Werkzeug>=0.7 in ./venv/lib/python3.5/site-packages (from Flask->Flask-WTF)
Requirement already satisfied: Jinja2>=2.4 in ./venv/lib/python3.5/site-packages (from Flask->Flask-WTF)
Requirement already satisfied: MarkupSafe>=0.23 in ./venv/lib/python3.5/site-packages (from Jinja2>=2.4->Flask->Flask-WTF)
Building wheels for collected packages: WTForms
  Running setup.py bdist_wheel for WTForms ... done
  Stored in directory: /home/sadmin/.cache/pip/wheels/36/35/f3/7452cd24daeeaa5ec5b2ea13755316abc94e4e7702de29ba94
Successfully built WTForms
Installing collected packages: WTForms, Flask-WTF
Successfully installed Flask-WTF-0.14.2 WTForms-2.1
(venv) sadmin@debian:~/workspace/selfish$ 

Логин.

(venv) sadmin@debian:~/workspace/selfish$ pip install Flask-Login
Collecting Flask-Login
  Downloading Flask-Login-0.4.1.tar.gz
Requirement already satisfied: Flask in ./venv/lib/python3.5/site-packages (from Flask-Login)
Requirement already satisfied: Jinja2>=2.4 in ./venv/lib/python3.5/site-packages (from Flask->Flask-Login)
Requirement already satisfied: Werkzeug>=0.7 in ./venv/lib/python3.5/site-packages (from Flask->Flask-Login)
Requirement already satisfied: click>=2.0 in ./venv/lib/python3.5/site-packages (from Flask->Flask-Login)
Requirement already satisfied: itsdangerous>=0.21 in ./venv/lib/python3.5/site-packages (from Flask->Flask-Login)
Requirement already satisfied: MarkupSafe>=0.23 in ./venv/lib/python3.5/site-packages (from Jinja2>=2.4->Flask->Flask-Login)
Building wheels for collected packages: Flask-Login
  Running setup.py bdist_wheel for Flask-Login ... done
  Stored in directory: /home/sadmin/.cache/pip/wheels/25/4b/53/738919150a881bdebf1e2a7885fa7610a1ff7ff3e113a55fe1
Successfully built Flask-Login
Installing collected packages: Flask-Login
Successfully installed Flask-Login-0.4.1
(venv) sadmin@debian:~/workspace/selfish$ 

И, наконец, валидация адресов.

(venv) sadmin@debian:~/workspace/selfish$ pip install validate-email
Collecting validate-email
  Downloading validate_email-1.3.tar.gz
Building wheels for collected packages: validate-email
  Running setup.py bdist_wheel for validate-email ... done
  Stored in directory: /home/sadmin/.cache/pip/wheels/9b/40/7b/ba10e92e80f0d5541b568eedfff3cc3dedbd3675df538c1dbb
Successfully built validate-email
Installing collected packages: validate-email
Successfully installed validate-email-1.3
(venv) sadmin@debian:~/workspace/selfish$ 

Сохраняю изменения в файл requirements.txt.

(venv) sadmin@debian:~/workspace/selfish$ pip freeze > requirements.txt 
(venv) sadmin@debian:~/workspace/selfish$ 

Виртуальное окружение Selfish получило все необходимые дополнения.

5. База данных для Selfish

Вся дальнейшая разработка Selfish потребует отдельную базу данных на сервере PostgreSQL. Поскольку конфигурация Selfish предусматривает три режима работы приложения (Development, Testing, Production), я создам для каждого из этих режимов отдельную базу данных. Для режима Development база данных будет иметь имя selfish_d, для режима Testing - selfish_t, для режима Production - selfish.

Подключаюсь к серверу PostgreSQL (см. Организация рабочего места выше).

(venv) sadmin@debian:~/workspace/selfish$ psql -h 192.168.56.102 -U sadmin -d startpoint
Пароль пользователя sadmin: 
psql (9.6.6)
SSL-соединение (протокол: TLSv1.2, шифр: ECDHE-RSA-AES256-GCM-SHA384, бит: 256, сжатие: выкл.)
Введите "help", чтобы получить справку.

startpoint=> 

Создаю три базы данных и прерываю сессию psql.

startpoint=> CREATE DATABASE selfish;
CREATE DATABASE
startpoint=> CREATE DATABASE selfish_d;
CREATE DATABASE
startpoint=> CREATE DATABASE selfish_t;
CREATE DATABASE
startpoint=> \q
(venv) sadmin@debian:~/workspace/selfish$ 

Имена только что созданных баз потребуются на следующем шаге и будут вписаны в соответствующие атрибуты конфигурационных классов Selfish.

6. Дополнительная конфигурация Selfish

Открываю файл config.py. Первой строчкой этого файла дописываю дополнительный импорт.

import os

Дописываю в класс Config в этом файле следующие свойства.

class Config:
    SECRET_KEY = 'My Secret Key'
    SITE_NAME = 'Selfish'
    PERMANENT_SESSION_LIFETIME = timedelta(hours=2)
    SQLALCHEMY_COMMIT_ON_TEARDOWN = True
    SQLALCHEMY_TRACK_MODIFICATIONS = False

PERMANENT_SESSION_LIFETIME определяет время жизни постоянной пользовательской сессии.

Два других свойства определяют опциональное поведение SQLAlchemy.

В классе Development дописываю два новых свойства.

class Development(Config):
    DEBUG = True
    ASSETS_DEBUG = True
    SEND_FILE_MAX_AGE_DEFAULT = 0
    SESSION_PROTECTION = None
    SQLALCHEMY_DATABASE_URI = \
        'postgresql+psycopg2://{0}:{1}@192.168.56.102/selfish_d'.format(
            os.getenv('SDBU', 'sadmin'),
            os.getenv('SDBP', 'aa'))

SESSION_PROTECTION определяет каким образом будет защищена пользовательская сессия, в режиме разработки и отладки защита не требуется, поэтому значение None.

Следует обратить особое внимание на SQLALCHEMY_DATABASE_URI, это свойство определяет, что приложение Selfish в режиме Development будет подключаться к серверу PostgreSQL посредством адаптера psycopg2. Адрес сервера - 192.168.56.102. Имя базы данных, к которой будет подключаться приложение, - selfish_d, эта база была создана на предыдущем шаге. При подключении к серверу будут использоваться: имя зарегистрированного на сервере PostgreSQL пользователя и его пароль, указанный пользователь должен иметь права на запись в указанную базу данных. Имя и пароль пользователя могут быть определены в соответствующих переменных окружения Linux, в данном случае SDBU и SDBP, или вписаны в соответствующих аргументах форматирования строки, в данном случае sadmin и aa - именно от имени этого пользователя были созданы базы данных на предыдущем шаге (см. База данных для Selfish выше). Переменные окружения Linux и причины их использования с Python3 детально описаны в Переменные окружения Linux в Python3.

В классы Testing и Production дописываю этот же атрибут, только в значении использую персональное для каждого класса имя базы данных.

class Testing(Config):
    TESTING = True
    SQLALCHEMY_DATABASE_URI = \
        'postgresql+psycopg2://{0}:{1}@192.168.56.102/selfish_t'.format(
            os.getenv('SDBU', 'sadmin'),
            os.getenv('SDBP', 'aa'))


class Production(Config):
    SEND_FILE_MAX_AGE_DEFAULT = int(timedelta(days=28).total_seconds())
    SQLALCHEMY_DATABASE_URI = \
        'postgresql+psycopg2://{0}:{1}@192.168.56.102/selfish'.format(
            os.getenv('SDBU', 'sadmin'),
            os.getenv('SDBP', 'aa'))

Открываю файл selfish/__init__.py и дописываю в него новый код.

selfish-init

Здесь я изменил первый импорт.

from flask import Flask, session

Это даст возможность настроить пользовательскую сессию.

Далее я импортировал необходимые классы из установленных дополнительных пакетов (см. Установка дополнительных пакетов).

from flask_login import LoginManager
from flask_sqlalchemy import SQLAlchemy
from flask_wtf.csrf import CSRFProtect

Создал экземпляры этих классов.

db = SQLAlchemy()
csrf = CSRFProtect()
lm = LoginManager()
lm.login_view = 'auth.login'
lm.login_message = 'Только для зарегистрированных пользователей.'

Объект db даёт доступ к базе данных посредством ORM SQLAlchemy и возможность манипулировать с данными в терминах Питона.

Объект csrf определяет глобальную CSRF защиту всех функций представления с методом POST и позволяет управлять этой защитой. На текущем этапе разработки этот объект не будет использован, но понадобится позже, поэтому он добавлен в конфигурацию приложения.

Объект lm - логин менеджер - управляет пользовательской сессией. При помощи соответствующих свойств этого объекта я задал функцию представления, в которой будет определена процедура входа в сервис - эта функция представления уже создана (см. Страница входа в сервис выше), и сообщение, которое будет выдаваться неавторизованным пользователям на страницах с ограниченным доступом.

Затем в теле функции create_app я сделал пользовательскую сессию постоянной.

    app.before_request(lambda: setattr(session, 'permanent', True))

Постоянная пользовательская сессия не прерывается сразу после закрытия web-браузера, а прерывается по истечение срока, определённого в PERMANENT_SESSION_LIFETIME в файле config.py.

Следом за этим я инициировал созданные объекты.

    db.init_app(app)
    csrf.init_app(app)
    lm.init_app(app)

Осталось соответствующим образом изменить менеджер приложения. Открываю файл manage.py и пишу в него новый код.

manage.py

Здесь я импортировал необходимые классы из flask_migrate.

from flask_migrate import Migrate, MigrateCommand

Из selfish я импортировал дополнительно объект db.

from selfish import create_app, db

Создал объект migrate.

migrate = Migrate(app, db)

Создал новую команду менеджера.

manager.add_command('db', MigrateCommand)

Добавил объект db в контекст служебной консоли.

def make_shell_context():
    return {'app': app, 'db': db}

В справке менеджера появилось описание новой команды.

(venv) sadmin@debian:~/workspace/selfish$ python manage.py --help
usage: manage.py [-?] {shell,db,runserver} ...

positional arguments:
  {shell,db,runserver}
    shell               Runs a Python shell inside Flask application context.
    db                  Perform database migrations
    runserver           Runs the Flask development server i.e. app.run()

optional arguments:
  -?, --help            show this help message and exit

При помощи этой команды можно осуществлять миграции базы данных. У этой команды тоже есть собственная справка.

(venv) sadmin@debian:~/workspace/selfish$ python manage.py db --help
usage: Perform database migrations

Perform database migrations

positional arguments:
  {init,revision,migrate,edit,merge,upgrade,downgrade,show,history,heads,branches,current,stamp}
    init                Creates a new migration repository
    revision            Create a new revision file.
    migrate             Alias for 'revision --autogenerate'
    edit                Edit current revision.
    merge               Merge two revisions together. Creates a new migration
                        file
    upgrade             Upgrade to a later version
    downgrade           Revert to a previous version
    show                Show the revision denoted by the given symbol.
    history             List changeset scripts in chronological order.
    heads               Show current available heads in the script directory
    branches            Show current branch points
    current             Display the current revision for each database.
    stamp               'stamp' the revision table with the given revision;
                        don't run any migrations

optional arguments:
  -?, --help            show this help message and exit

С этого момента приложение готово определять модели хранения данных и с их помощью работать с данными.

7. Работа над ошибками

Поскольку у Selfish появились новые возможности, и функционал приложения в ближайшей перспективе расширится, следует ещё раз взглянуть на обработку исключений. Открываю в PyCharm файл selfish/main/errors.py. Несложно заметить, что редактор откомментировал некоторые проблемные места в коде.

pycharm-inspection

Таких мест в коде три, и код очень не сложно исправить. Делаю это.

pycharm-happy

Теперь у редактора нет замечаний.

Так как в приложении появилась CSRF-защита запросов, в обработчик исключений следует добавить ещё одну функцию представления. Дописываю в этот файл дополнительный импорт.

from flask_wtf.csrf import CSRFError

И новую функцию обработчика ошибок.

@main.app_errorhandler(CSRFError)
def handle_csrf_error(e):
    return render_template(
        'error.html', error='CSRF Error', reason=e.description), e.code

Инспекции PyCharm очень полезны, и на них следует всегда обращать внимание, чистота кода - залог душевного спокойствия и удовлетворённости программиста. :)

8. Модель пользовательского аккаунта

Прежде чем создать модель пользовательского аккаунта, скажу несколько слов о замысле, на основании которого система авторизации пользователей Selfish будет строиться.

Система авторизации Selfish предполагает двух-шаговую регистрацию новых пользователей.

На первом шаге пользователь вводит в специальную форму адрес своей электронной почты. Система создаёт пользовательский аккаунт, привязанный к этому адресу, и высылает пользователю письмо с регистрационной ссылкой. Таким образом пользовательский аккаунт определяется адресом электронной почты и может существовать и не иметь пользователя.

На втором шаге пользователь, получив письмо с регистрационной ссылкой, следует по этой ссылке и оказывается на странице, где ему предлагается ввести в специальную форму желаемый псевдоним в сервисе и желаемый пароль (пароль вводится два раза). Пользователь заполняет и отсылает форму, система проверяет правильность введённых данных, и, если не находит ошибок, регистрирует пользователя и привязывает к пользователю его аккаунт, созданный на первом шаге регистрации.

Таким образом аккаунт может существовать без пользователя сколь угодно долго, но пользователь может быть зарегистрирован, только если у него уже есть аккаунт. На один аккаунт можно зарегистрировать одного пользователя. Запросить письмо с регистрационной ссылкой можно один раз в сутки бесконечное количество раз до регистрации пользователя.

Сейчас я буду создавать первую модель - пользовательский аккаунт. Создаю новый каталог.

(venv) sadmin@debian:~/workspace/selfish$ mkdir selfish/models
(venv) sadmin@debian:~/workspace/selfish$ 

Создаю в этом каталоге три новых файла __init__.py, auth.py и exc.py.

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/models/__init__.py
(venv) sadmin@debian:~/workspace/selfish$ touch selfish/models/auth.py
(venv) sadmin@debian:~/workspace/selfish$ touch selfish/models/exc.py
(venv) sadmin@debian:~/workspace/selfish$ 

Открываю файл selfish/models/exc.py в редакторе и пишу в него следующий код.

class MissingArgument(Exception):
    pass


class BadArgument(Exception):
    pass

Эти два класса понадобятся при создании моделей.

Открываю файл selfish/models/auth.py и создаю модель данных для пользовательского аккаунта.

from datetime import datetime
from hashlib import md5

from flask import request

from .. import db
from .exc import BadArgument, MissingArgument


class Account(db.Model):
    __tablename__ = 'accounts'

    id = db.Column(db.Integer, primary_key=True)
    address = db.Column(db.String(128), unique=True, nullable=False)
    ava_hash = db.Column(db.String(32), nullable=False)
    requested = db.Column(db.DateTime, default=datetime.utcnow)

    def __init__(self, **kwargs):
        db.Model.__init__(self, **kwargs)
        if 'address' not in kwargs:
            raise MissingArgument('missing required argument: "address"')
        if not kwargs['address'] or not isinstance(kwargs['address'], str):
            raise BadArgument('bad required argument: "address"')

    def __repr__(self):
        return '<Account: {}>'.format(self.address)

    @staticmethod
    def on_changed_address(target, value, oldvalue, initiator):
        if not value or not isinstance(value, str):
            return None
        target.ava_hash = md5(value.encode('utf-8')).hexdigest()

    def get_ava_url(self, size=100, default='mm', rating='g'):
        if request.is_secure:
            url = 'https://secure.gravatar.com/avatar'
        else:
            url = 'http://www.gravatar.com/avatar'
        return '{0}/{1}?s={2}&d={3}&r={4}'.format(
            url, self.ava_hash, size, default, rating)


db.event.listen(Account.address, 'set', Account.on_changed_address)

Здесь я определил класс Account, который является моделью данных таблицы с именем accounts. Дал классу необходимые атрибуты, каждый атрибут соответствует столбцу таблицы accounts. В address будет храниться адрес электронной почты - уникальная строка не более 128 символов. Следующий атрибут ava_hash будет хранить хеш для сервиса gravatar.com, с его помощью можно будет определить аватарку пользователя. Атрибут requested будет хранить время последнего успешного запроса регистрационной ссылки.

Класс Account имеет метод __init__, который определяет инициализацию экземпляров этого класса. В данном случае в этом методе производится проверка переданных аргументов и определён обязательный для создания экземпляра аргумент address.

Метод __repr__ определяет возвращаемое экземпляром класса значение.

Статический метод on_changed_address пересчитывает ava_hash при изменении адреса экземпляра класса, в данном случае тоже происходит проверка переданного значения атрибуту address.

Последний метод возвращает валидный для сервиса gravatar.com url-адрес.

В последней строке файла объекту db определён обработчик события, который при каждой смене атрибута address будет исполнять соответствующий статический метод.

Открываю файл manage.py в нём импортирую созданный класс Account и добавляю этот класс в контекст служебной консоли.

from selfish.models.auth import Account

...

def make_shell_context():
    return {'app': app, 'db': db, 'Account': Account}

Перехожу в консоль и инициирую миграции.

(venv) sadmin@debian:~/workspace/selfish$ python manage.py db init
  Creating directory /home/sadmin/workspace/selfish/migrations ... done
  Creating directory /home/sadmin/workspace/selfish/migrations/versions ... done
  Generating /home/sadmin/workspace/selfish/migrations/env.py ... done
  Generating /home/sadmin/workspace/selfish/migrations/script.py.mako ... done
  Generating /home/sadmin/workspace/selfish/migrations/README ... done
  Generating /home/sadmin/workspace/selfish/migrations/alembic.ini ... done
  Please edit configuration/connection/logging settings in
  '/home/sadmin/workspace/selfish/migrations/alembic.ini' before proceeding.
(venv) sadmin@debian:~/workspace/selfish$ 

В корне проекта появился новый каталог migrations.

(venv) sadmin@debian:~/workspace/selfish$ ls -1
config.py
config.template.py
manage.py
migrations        # новый каталог
__pycache__
README.md
requirements-first.txt
requirements.txt
selfish
tests
venv
(venv) sadmin@debian:~/workspace/selfish$ 

Делаю первую миграцию.

(venv) sadmin@debian:~/workspace/selfish$ python manage.py db migrate -m"Create Account"
INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO  [alembic.runtime.migration] Will assume transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'accounts'
  Generating /home/sadmin/workspace/selfish/migrations/versions/2cb33e363edc_creat
  e_account.py ... done
(venv) sadmin@debian:~/workspace/selfish$ 

Обновляю базу данных.

(venv) sadmin@debian:~/workspace/selfish$ python manage.py db upgrade
INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO  [alembic.runtime.migration] Will assume transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade  -> 2cb33e363edc, Create Account
(venv) sadmin@debian:~/workspace/selfish$ 

Подключаюсь к серверу PostgreSQL к базе данных selfish_d.

(venv) sadmin@debian:~/workspace/selfish$ psql -h 192.168.56.102 -U sadmin -d selfish_d
Пароль пользователя sadmin: 
psql (9.6.6)
SSL-соединение (протокол: TLSv1.2, шифр: ECDHE-RSA-AES256-GCM-SHA384, бит: 256, сжатие: выкл.)
Введите "help", чтобы получить справку.

selfish_d=> 

Пристально смотрю на результат трудов праведных.

selfish_d=> \dt
               Список отношений
 Схема  |       Имя       |   Тип   | Владелец 
--------+-----------------+---------+----------
 public | accounts        | таблица | sadmin
 public | alembic_version | таблица | sadmin
(2 строки)

selfish_d=> 

В базе данных появились две таблицы. Меня интересует таблица accounts.

selfish_d=> \d accounts
                                    Таблица "public.accounts"
  Столбец  |             Тип             |                     Модификаторы                      
-----------+-----------------------------+-------------------------------------------------------
 id        | integer                     | NOT NULL DEFAULT nextval('accounts_id_seq'::regclass)
 address   | character varying(128)      | NOT NULL
 ava_hash  | character varying(32)       | NOT NULL
 requested | timestamp without time zone | 
Индексы:
    "accounts_pkey" PRIMARY KEY, btree (id)
    "accounts_address_key" UNIQUE CONSTRAINT, btree (address)

selfish_d=> 

Естественно, что таблица на текущий момент не содержит ни одной строки.

selfish_d=> SELECT * FROM accounts;
 id | address | ava_hash | requested 
----+---------+----------+-----------
(0 строк)

selfish_d=> 

Модель пользовательского аккаунта почти готова, в дальнейшем она получит дополнительные атрибуты и отношения. А пока попробую создать первый пользовательский аккаунт, для этого подгружаю служебную консоль.

(venv) sadmin@debian:~/workspace/selfish$ python manage.py shell
>>> 

Пробую создать экземпляр класса Account без аргументов.

>>> support = Account()
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "<string>", line 4, in __init__
  File "/home/sadmin/workspace/selfish/venv/lib/python3.5/site-packages/sqlalchemy/orm/state.py", line 417, in _initialize_instance
    manager.dispatch.init_failure(self, args, kwargs)
  File "/home/sadmin/workspace/selfish/venv/lib/python3.5/site-packages/sqlalchemy/util/langhelpers.py", line 66, in __exit__
    compat.reraise(exc_type, exc_value, exc_tb)
  File "/home/sadmin/workspace/selfish/venv/lib/python3.5/site-packages/sqlalchemy/util/compat.py", line 187, in reraise
    raise value
  File "/home/sadmin/workspace/selfish/venv/lib/python3.5/site-packages/sqlalchemy/orm/state.py", line 414, in _initialize_instance
    return manager.original_init(*mixed[1:], **kwargs)
  File "/home/sadmin/workspace/selfish/selfish/models/auth.py", line 21, in __init__
    raise MissingArgument('missing required argument: "address"')
selfish.models.exc.MissingArgument: missing required argument: "address"
>>> 

Исключение. Отлично, пробую передать аргумент address.

>>> support = Account(address='avm@elardy.ru')
>>> db.session.add(support)
>>> db.session.commit()
>>> 

Посмотрим на свойства полученного объекта.

>>> support.address
'avm@elardy.ru'
>>> support.ava_hash
'a9032ef0ef224f943e4a07d09bf7eab9'
>>> support.requested
datetime.datetime(2018, 3, 9, 9, 4, 15, 712141)
>>> 

Возвращаемое значение.

>>> support
<Account: avm@elardy.ru>
>>> 

Изменяю адрес аккаунта и проверяю ava_hash.

>>> support.address = 'support@elardy.ru'
>>> db.session.add(support)
>>> db.session.commit()
>>> support.ava_hash
'6c3df0f4a4ba25c87238b4d53825e07c'
>>> 

Хеш изменился автоматически. Получаю url аватарки.

>>> support.get_ava_url(size=36)
'http://www.gravatar.com/avatar/6c3df0f4a4ba25c87238b4d53825e07c?s=36&d=mm&r=g'
>>> 

Проверяю полученный url в браузере.

ava-url

Покидаю служебную консоль.

>>> exit()
(venv) sadmin@debian:~/workspace/selfish$ 

Подключаюсь к серверу PostgreSQL к базе данных selfish_d.

(venv) sadmin@debian:~/workspace/selfish$ psql -h 192.168.56.102 -U sadmin -d selfish_d
Пароль пользователя sadmin: 
psql (9.6.6)
SSL-соединение (протокол: TLSv1.2, шифр: ECDHE-RSA-AES256-GCM-SHA384, бит: 256, сжатие: выкл.)
Введите "help", чтобы получить справку.

selfish_d=> 

И смотрю содержимое таблицы accounts.

selfish_d=> SELECT * FROM accounts;
 id |      address      |             ava_hash             |         requested          
----+-------------------+----------------------------------+----------------------------
  1 | support@elardy.ru | 6c3df0f4a4ba25c87238b4d53825e07c | 2018-03-09 09:04:15.712141
(1 строка)

selfish_d=> 

Пользовательский аккаунт заработал. В базе данных появилась первая запись в таблице accounts, и теперь можно двигаться дальше.

9. Модель пользователя

Модель пользователя будет храниться в том же файле selfish/models/auth.py. Класс User будет наследовать от класса UserMixin из пакета flask_login, импортирую этот класс.

from flask_login import UserMixin

Создаю класс User, задаю имя таблицы.

class User(UserMixin, db.Model):
    __tablename__ = 'users'

Создаю атрибуты этого класса.

    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(16), unique=True, nullable=False)

Пользователь в сервисе будет определяться псевдонимом - username - это будет уникальная строка длиной не более 16 символов.

    registered = db.Column(db.DateTime, default=datetime.utcnow)
    last_visit = db.Column(db.DateTime, default=datetime.utcnow)

В поле registered будет храниться дата и время регистрации пользователя. В поле last_visit будет храниться дата и время последнего визита пользователя.

    password_hash = db.Column(db.String(128))

Поле password_hash будет хранить зашифрованный пароль пользователя. С этим паролем пользователь сможет входить в сервис.

Пользователь должен быть связан со своим аккаунтом, для этого пишу в свойствах класса User следующее.

    account = db.relationship('Account', back_populates='user', uselist=False)

Классу Account добавляю два новых свойства.

class Account(db.Model):
    ...
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
    user = db.relationship('User', back_populates='account')

Таким образом User и Account связаны отношением One-to-One.

Возвращаюсь к классу User. Определяю порядок инициализации экземпляров этого класса.

    def __init__(self, **kwargs):
        UserMixin.__init__(self)
        db.Model.__init__(self, **kwargs)
        if 'username' not in kwargs:
            raise MissingArgument('missing required argument: "username"')
        if 'password' not in kwargs:
            raise MissingArgument('missing required argument: "password"')
        if not kwargs['username'] or not isinstance(kwargs['username'], str):
            raise BadArgument('bad required argument: "username"')

Здесь я определил два обязательных аргумента: username и password. Но класс пока не имеет свойства password - оно будет определено особым способом чуть позже.

Далее определяю возвращаемое значение для экземпляров класса User.

    def __repr__(self):
        return '<User: {}>'.format(self.username)

Поскольку пароль пользователя будет храниться в зашифрованном виде в поле password_hash, доступ к свойству password следует закрыть. Задаю свойство password так.

    @property
    def password(self):
        raise AttributeError('password is not a readable attribute')

Теперь нужно определить методы установки пароля и его верификации, для этого понадобится специальный эквипмент, который предоставляется сервером Werkzeug. Напомню, сервер Werkzeug установился в виртуальное окружение Selfish по зависимостям при установке Flask. Импортирую.

from werkzeug.security import check_password_hash, generate_password_hash

Возвращаюсь в класс User и дописываю два новых метода.

    @password.setter
    def password(self, password):
        if not password or not isinstance(password, str):
            raise BadArgument('bad required argument: "password"')
        self.password_hash = generate_password_hash(
            password, method='pbkdf2:sha256')

    def verify_password(self, password):
        return check_password_hash(self.password_hash, password)

Для полного счастья на данном этапе разработки мне потребуется ещё один метод, который будет изменять свойство last_visit. Дописываю в User.

    def ping(self):
        self.last_visit = datetime.utcnow()
        db.session.add(self)
        db.session.commit()

Модель пользователя готова. Кроме зарегистрированных пользователей сервис Selfish должен определять ещё и анонимных пользователей. Дописываю в этот же файл ещё один класс. Импортирую из flask_login класс AnonymousUserMixin.

from flask_login import AnonymousUserMixin, UserMixin

Импортирую логин-менеджера.

from .. import db, lm

Создаю анонимуса и регистрирую его в менеджере.

class AnonymousUser(AnonymousUserMixin):
    pass


lm.anonymous_user = AnonymousUser

Чуть позже в процессе разработки AnonymousUser получит пару необходимых ему методов. А на данном этапе осталось определить загрузку пользователя в логин-менеджере. Пишу в самый конец этого же файла.

@lm.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))

Открываю файл manage.py. В нём импортирую только что созданный класс User и добавляю этот класс в контекст служебной консоли.

from selfish.models.auth import Account, User

...

def make_shell_context():
    return {'app': app, 'db': db, 'Account': Account, 'User': User}

Мигрирую.

(venv) sadmin@debian:~/workspace/selfish$ python manage.py db migrate -m"Create User"
INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO  [alembic.runtime.migration] Will assume transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'users'
INFO  [alembic.ddl.postgresql] Detected sequence named 'accounts_id_seq' as owned by integer column 'accounts(id)', assuming SERIAL and omitting
INFO  [alembic.autogenerate.compare] Detected added column 'accounts.user_id'
INFO  [alembic.autogenerate.compare] Detected added foreign key (user_id)(id) on table accounts
  Generating
  /home/sadmin/workspace/selfish/migrations/versions/d1993d37afb0_create_user.py
  ... done
(venv) sadmin@debian:~/workspace/selfish$ 

Затем обновляю базу данных.

(venv) sadmin@debian:~/workspace/selfish$ python manage.py db upgrade
INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO  [alembic.runtime.migration] Will assume transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade 2cb33e363edc -> d1993d37afb0, Create User
(venv) sadmin@debian:~/workspace/selfish$ 

Подключаюсь к базе данных и смотрю что изменилось.

Появилась новая таблица.

selfish_d=> \dt
               Список отношений
 Схема  |       Имя       |   Тип   | Владелец 
--------+-----------------+---------+----------
 public | accounts        | таблица | sadmin
 public | alembic_version | таблица | sadmin
 public | users           | таблица | sadmin
(3 строки)

selfish_d=> 

Таблица accounts получила новые данные.

selfish_d=> \d accounts
                                    Таблица "public.accounts"
  Столбец  |             Тип             |                     Модификаторы                      
-----------+-----------------------------+-------------------------------------------------------
 id        | integer                     | NOT NULL DEFAULT nextval('accounts_id_seq'::regclass)
 address   | character varying(128)      | NOT NULL
 ava_hash  | character varying(32)       | NOT NULL
 requested | timestamp without time zone | 
 user_id   | integer                     | 
Индексы:
    "accounts_pkey" PRIMARY KEY, btree (id)
    "accounts_address_key" UNIQUE CONSTRAINT, btree (address)
Ограничения внешнего ключа:
    "accounts_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id)

selfish_d=> 

Таблица users имеет следующий вид.

selfish_d=> \d users
                                      Таблица "public.users"
    Столбец    |             Тип             |                    Модификаторы                    
---------------+-----------------------------+----------------------------------------------------
 id            | integer                     | NOT NULL DEFAULT nextval('users_id_seq'::regclass)
 username      | character varying(16)       | NOT NULL
 registered    | timestamp without time zone | 
 last_visit    | timestamp without time zone | 
 password_hash | character varying(128)      | 
Индексы:
    "users_pkey" PRIMARY KEY, btree (id)
    "users_username_key" UNIQUE CONSTRAINT, btree (username)
Ссылки извне:
    TABLE "accounts" CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id)

selfish_d=> 

Можно регистрировать первого пользователя.

10. Регистрация первого пользователя

Поскольку в результате этого перформанса должна появиться полноценная страница входа пользователей в сервис, мне необходим пользователь, на примере которого я смогу продемонстрировать работу этой страницы. Для регистрации первого пользователя в настоящий момент у Selfish есть только служебная консоль. Запускаю её.

(venv) sadmin@debian:~/workspace/selfish$ python manage.py shell
>>> 

Воспроизвожу порядок возможных действий. Создаю пользовательский аккаунт.

>>> avm = Account(address='avm@elardy.ru')
>>> 

Создаю пользователя.

>>> avm_u = User(username='avm', password='abcd')
>>> 

Связываю пользователя с аккаунтом и сохраняю в базу.

>>> avm_u.account = avm
>>> db.session.add(avm_u)
>>> db.session.commit()
>>> 

Теперь мне хочется взглянуть на пользователя чуть-чуть более пристально. Начну с возвращаемого значения.

>>> avm_u
<User: avm>
>>> 

Меня интересует пароль этого пользователя.

>>> avm_u.password
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/home/sadmin/workspace/selfish/selfish/models/auth.py", line 75, in password
    raise AttributeError('password is not a readable attribute')
AttributeError: password is not a readable attribute
>>> 

Пароль не показывает. Пробую верифицировать разные варианты.

>>> avm_u.verify_password('aa')
False
>>> avm_u.verify_password('abcd')
True
>>> 

Верифицируется только заданный пароль. Отлично. Пробую изменить пароль.

>>> avm_u.password = 'aa'
>>> db.session.add(avm_u)
>>> db.session.commit()
>>> avm_u.verify_password('aa')
True
>>> avm_u.verify_password('abcd')
False
>>> 

Посмотрим на last_visit.

>>> avm_u.last_visit
datetime.datetime(2018, 3, 10, 7, 2, 19, 920355)
>>> 

Пробую изменить это свойство.

>>> avm_u.ping()
>>> avm_u.last_visit
datetime.datetime(2018, 3, 10, 7, 11, 4, 32391)
>>> 

Пробую получить доступ к свойствам аккаунта через пользователя.

>>> avm_u.account.address
'avm@elardy.ru'
>>> avm_u.account.get_ava_url()
'http://www.gravatar.com/avatar/a9032ef0ef224f943e4a07d09bf7eab9?s=100&d=mm&r=g'
>>> 

Как говорят англичане, it looks like everything works fine! Поскольку User наследует от UserMixin, у пользователя есть свойства и методы определённые в предке.

>>> avm_u.is_active
True
>>> avm_u.is_anonymous
False
>>> avm_u.is_authenticated
True
>>> avm_u.get_id()
'1'
>>> 

Эти свойства пользователя будут очень полезны и найдут своё применение на следующих стадиях разработки Selfish.

Покидаю консоль, подключаюсь к базе данных с psql и смотрю, что изменилось. Содержимое accounts.

selfish_d=> SELECT * FROM accounts;
 id |      address      |             ava_hash             |         requested          | user_id 
----+-------------------+----------------------------------+----------------------------+---------
  1 | support@elardy.ru | 6c3df0f4a4ba25c87238b4d53825e07c | 2018-03-09 09:04:15.712141 |        
  2 | avm@elardy.ru     | a9032ef0ef224f943e4a07d09bf7eab9 | 2018-03-10 07:02:19.925446 |       1
(2 строки)

selfish_d=> 

Пользовательский id есть только на одном аккаунте, что и ожидалось. В таблице users появилась одна запись.

selfish_d=> SELECT * FROM users;
 id | username |         registered         |         last_visit         |                                         password_hash                                         
----+----------+----------------------------+----------------------------+-----------------------------------------------------------------------------------------------
  1 | avm      | 2018-03-10 07:02:19.920333 | 2018-03-10 07:11:04.032391 | pbkdf2:sha256:50000$Bhd0ur99$552608a062aeee5af4208daa3cb7cfe361c21b9b3d92d0038ca49a0604683059
(1 строка)

selfish_d=> 

Первые модели данных заработали. Далее я покажу как их можно использовать для авторизации пользователей в сервисе.

11. Форма для входа в сервис

Для страницы входа в сервис необходима соответствующая форма. Форма - это пользовательский интерфейс на стороне клиента, посредством которого вводятся и отсылаются на сервер пользовательские данные. На сервере введённые в форму данные соответствующим образом обрабатываются и проверяются, после чего программа выполняет с этими данными определённые действия. Объект, при помощи которого серверная часть web-приложения обрабатывает форму обычно так и называют - форма. Сейчас я создам класс формы для входа в сервис Selfish. Открываю в редакторе файл selfish/auth/forms.py и пишу в него следующий код.

login-form

Здесь я импортировал нужный эквипмент из пакетов Flask-WTF и WTForms, создал класс LoginForm и определил ему необходимые свойства. При помощи этого класса впоследствии будет создана форма и передана соответствующему шаблону. Об этом далее.

12. Логика процедуры входа в сервис

Страница для входа в сервис, вернее соответствующий ей url и функция представления уже существуют. Пришло время дать этой странице необходимый функционал и определить логическую последовательность выполняемых программой действий. Открываю в редакторе файл selfish/auth/views.py и редактирую его.

login-view

Функция преставления получила возможность обрабатывать HTTP-запросы по методам GET и POST. В теле функции появилась проверка текущего пользователя.

    if current_user.is_authenticated:
        return redirect(url_for('main.show_index'))

Объект current_user импортирован из пакета flask_login и представляет текущего пользователя, если текущий пользователь авторизован, то current_user будет экземпляром класса User, в другом случае этот объект будет экземпляром класса AnonymousUser - так определено в логин-менеджере. Свойство is_authenticated авторизованного пользователя возвращает True - это было показано при регистрации пользователя выше. Таким образом, если GET-запрос на заданный в этой функции представления url отправляет авторизованный пользователь - он тут же будет перенаправлен на главную страницу посредством redirect и url_for - инструментов из набора Flask.

Если GET-запрос отправлен анонимным пользователем, то выполняется следующий блок, создаётся форма.

    form = LoginForm()

Рендерится шаблон.

    return render_template('auth/login.html', form=form)

А пользователь оказывается на странице и видит перед собой форму для ввода логина и пароля. Поля формы описаны в классе LoginForm. Форма имеет два обязательных для заполнения поля: login и password, и кнопку submit для отправки данных формы в POST-запросе. Форма передана в шаблон и соответствующим образом будет описана в шаблоне.

Пользователь вводит любые логин и пароль - обязательные данные, нажимает кнопку submit, при этом браузер отправит POST-запрос на этот же самый url-адрес. В этом случае в функции представления проверяется условие.

    if form.validate_on_submit():

И если метод validate_on_submit возвращает True, а это возможно только в случае, когда пользователь заполнил обязательные поля формы, выполняется следующий блок.

        user = None
        if validate_email(form.login.data):
            acc = Account.query.filter_by(address=form.login.data).first()
            if acc:
                user = acc.user
        else:
            user = User.query.filter_by(username=form.login.data).first()
        if user and user.verify_password(form.password.data):
            login_user(user, form.remember_me.data)
            return redirect(
                request.args.get('next', url_for('main.show_index')))
        flash('Неверный логин или пароль, вход невозможен.')

В ином случае снова рендерится шаблон и пользователь видит форму с ошибками валидации.

Теперь разберём код, который выполняется при POST-запросе, если форма заполнена верно. В этом случае создаётся переменная user.

        user = None

Проверяются данные введённые в форму в поле login, если в этом поле обнаруживается адрес электронной почты, то из базы данных фильтруется соответствующий этому адресу аккаунт.

        if validate_email(form.login.data):
            acc = Account.query.filter_by(address=form.login.data).first()

Функция validate_email импортирована из соответствующего пакета.

Затем проверяется, что аккаунт существует, и если так, то переменной user назначается соответствующее значение.

            if acc:
                user = acc.user

Если же функция validate_email возвращает False, это означает, что пользователь ввёл в соответствующее поле формы свой псевдоним, то выполняется следующий блок.

        else:
            user = User.query.filter_by(username=form.login.data).first()

И пользователь фильтруется из базы данных по введённому псевдониму.

Затем проверяется, что пользователь существует и введённый им пароль верифицируется.

        if user and user.verify_password(form.password.data):

В этом случае пользователь авторизуется посредством функции login_user и перенаправляется на заданный url адрес, который берётся из аргументов запроса или, если в аргументах запроса нет аргумента с именем next, задаётся адрес главной страницы.

            login_user(user, form.remember_me.data)
            return redirect(
                request.args.get('next', url_for('main.show_index')))

В случае, когда пользователя с введённым псевдонимом или адресом электронной почты не существует в базе данных, или введённый пароль не верифицируется, отправляется flash-сообщение и рендерится шаблон.

        flash('Неверный логин или пароль, вход невозможен.')
    return render_template('auth/login.html', form=form)

В этом случае пользователь остаётся анонимным, попадает на ту же страницу, опять видит ту же форму и информационное flash сообщение.

Чтобы покончить с серверной логикой на этом этапе, дописываю в этот же файл ещё две функции. Первая будет изменять свойство last_visit каждого авторизованного пользователя при каждом посланном им запросе к серверу посредством соответствующего метода ping.

@auth.before_app_request
def make_before_request():
    if current_user.is_authenticated:
        current_user.ping()

А вторая даст возможность авторизованному пользователю выйти из сервиса.

@auth.route('/logout')
@login_required
def logout():
    logout_user()
    return redirect(url_for('main.show_index'))

Здесь следует обратить внимание, что url этой функции представления доступен только авторизованным пользователям, это достигается посредством декоратора login_required импортированного из пакета flask_login. В теле функции выполняется logout_user из flask_login и происходит перенаправление на главную страницу сервиса. Соответствующие импорты должны быть дописаны в файл.

from flask_login import current_user, login_user, login_required, logout_user

Поскольку шаблон для страницы входа в сервис на текущий момент не существует, протестировать предложенную логику в браузере пока невозможно, сначала необходимо создать шаблон и описать в нём форму.

13. Шаблон страницы входа в сервис

Прежде чем приступить к созданию шаблона страницы входа в сервис необходимо обратить внимание на некоторые ключевые моменты.

Первое. Система авторизации пользователей Selfish кроме страницы входа в сервис будет иметь несколько других страниц, при помощи которых будет осуществляться регистрация и обслуживание аккаунта пользователя, а именно:

  • смена пароля,
  • восстановление забытого пароля,
  • регистрация аккаунта,
  • регистрация пользователя,
  • смена адреса электронной почты.

Все страницы системы авторизации будут иметь абсолютно идентичное оформление и структуру, поэтому имеет смысл объединить их базовым шаблоном системы авторизации. Создаю новый каталог и в нём файл базового шаблона.

(venv) sadmin@debian:~/workspace/selfish$ mkdir selfish/templates/auth
(venv) sadmin@debian:~/workspace/selfish$ touch selfish/templates/auth/auth_base.html
(venv) sadmin@debian:~/workspace/selfish$

Второе. Элементы интерфейса на перечисленных страницах будут абсолютно идентичны и некоторые из них будут повторяться. Чтобы в шаблонах не повторять целые блоки кода, повторяющиеся элементы необходимо описать при помощи macro. Создаю ещё один каталог и в нём новый файл.

(venv) sadmin@debian:~/workspace/selfish$ mkdir selfish/templates/macros
(venv) sadmin@debian:~/workspace/selfish$ touch selfish/templates/macros/_auth.html
(venv) sadmin@debian:~/workspace/selfish$ 

И наконец создаю файл шаблона страницы входа в сервис.

(venv) sadmin@debian:~/workspace/selfish$ touch selfish/templates/auth/login.html
(venv) sadmin@debian:~/workspace/selfish$ 

Теперь можно запустить отладочный сервер и постучаться в url-адрес страницы входа в сервис в браузере. Браузер отобразит пустую страницу, так как созданные шаблоны на текущий момент не имеют полезного кода.

empty-login-page

Открываю в редакторе файл selfish/templates/auth/auth_base.html и пишу в него следующий код.

{% extends "base.html" %}

{% block styles %}
  {{ super() }}
  {% assets filters='cssmin', output='generic/css/auth.css',
            'css/base.css' %}
    <link rel="stylesheet" type="text/css" href="{{ ASSET_URL }}">
  {% endassets %}
{% endblock styles %}

{% block page_body %}
  <div id="main-container" class="container-fluid">
    <div class="row">
      <div id="content" class="col-lg-6 col-md-8 col-sm-10
                               col-lg-offset-3 col-md-offset-2 col-sm-offset-1">
        {% block form_content %}{% endblock form_content %}
      </div>
    </div>
  </div>
{% endblock page_body %}

{% block scripts %}
  {{ super() }}

{% endblock scripts %}

Открываю в редакторе файл selfish/templates/auth/login.html и пишу в него такой код.

{% extends "auth/auth_base.html" %}

{% block title %}Вход в сервис{% endblock %}

{% block form_content %}
  <div class="form-block content-block"></div>
{% endblock form_content %}

Обновляю страницу в браузере.

empyt-login-page

Теперь следует остановиться на необходимых macro. Открываю в редакторе файл selfish/templates/macros/_auth.html. В нём я создам необходимые для системы авторизации macro. Мне нужно каким-то образом обрабатывать и отображать информационные flash-сообщения. Для этого пишу в этот файл следующий macro.

{% macro get_message(messages) %}
  {% for message in messages %}
    <div class="flashed-message {% if not loop.first %}next-block{% endif %}">
      <div class="alert alert-warning">
        <button class="close" type="button" data-dismiss="alert">
          &times;</button>
        {{ message }}
      </div>
    </div>
  {% endfor %}
{% endmacro %}

Мне необходимо обрабатывать и отображать ошибки валидации формы. Для этого следующий macro.

{% macro show_error(form, field) %}
  {% if form.errors[field] %}
    {% for error in form.errors[field] %}
      <div class="error">{{ error }}</div>
    {% endfor %}
  {% endif %}
{% endmacro %}

Мне необходимо обрабатывать и отображать поля формы для ввода пользовательских данных. Для этого ещё один macro.

{% macro render_form_group(form, field, fieldname, placeholder) %}
  <div class="form-group {% if form.errors[fieldname] %}has-error{% endif %}">
    <div class="form-label {% if form.errors[fieldname] %}error{% endif %}
                text-right">
      {{ field.label }}
    </div>
    <div class="form-input">
      {{ field(class="form-control", placeholder=placeholder) }}
      {{ show_error(form, fieldname) }}
    </div>
  </div>
{% endmacro %}

Форма для входа в сервис содержит поле с флажком, для его отображения следующий macro.

{% macro render_form_boolean(form, field, checked=False) %}
  <div class="form-group">
    <div class="form-input checkbox">
      <label>
        {{ field(checked=checked) }}{{ field.label }}
      </label>
    </div>
  </div>
{% endmacro %}

Далее возвращаюсь к файлу selfish/templates/auth/login.html, экспортирую в него полученные macro.

{% extends "auth/auth_base.html" %}
{% from "macros/_auth.html" import get_message %}
{% from "macros/_auth.html" import render_form_group %}
{% from "macros/_auth.html" import render_form_boolean %}

Формирую интерфейс страницы входа в сервис, в этом же файле пишу в блок form_content следующий код.

{% block form_content %}
  <div class="form-block content-block">
    <div class="block-header">
      <h3 class="panel-title">Вход в сервис</h3>
    </div>
    <div class="block-body">
      <div class="today-field"></div>
      <div class="form-help">
        <p>
          Для входа в сервис введите логин и пароль. Нажмите кнопку
          "Войти в сервис". Логином может быть зарегистрированный юзернэйм
          или адрес электронной почты.
        </p>
      </div>
      {% set messages = get_flashed_messages() %}
      {% if messages %}
        {{ get_message(messages) }}
      {% endif %}
      <div class="form-form">
        <form class="form" method="POST" role="form">
          {{ form.hidden_tag() }}
          {{ render_form_group(
              form, form.login, 'login', 'введите логин') }}
          {{ render_form_group(
              form, form.password, 'password', 'введите пароль') }}
          {{ render_form_boolean(form, form.remember_me) }}
          <div class="form-group">
            <div class="form-input">
              {{ form.submit(class="btn btn-primary") }}
            </div>
          </div>
        </form>
      </div>
    </div>
  </div>
{% endblock form_content %}

Обновляю страницу в браузере.

login-page

Собственно что и требовалось. Выглядит пока не очень, но это я сейчас исправлю.

14. Оформление страницы входа в сервис

Пришла пора поработать над внешним видом страниц системы авторизации. Напомню, все страницы системы авторизации будут иметь абсолютно идентичный внешний вид. Чтобы этого добиться, я буду править базовый шаблон системы авторизации - файл selfish/templates/auth/auth_base.html. Открываю его в редакторе. Привожу блок scripts в этом шаблоне к виду.

{% block scripts %}
  {{ super() }}
  {% assets filters='jsmin', output='generic/js/auth.js',
            'js/custom_moment.js', 'js/auth/auth.js' %}
    <script src="{{ ASSET_URL }}"></script>
  {% endassets %}
{% endblock scripts %}

Создаю новый каталог.

(venv) sadmin@debian:~/workspace/selfish$ mkdir selfish/static/js/auth
(venv) sadmin@debian:~/workspace/selfish$ 

Копирую в этот каталог файл selfish/static/js/main/index.js с новым именем.

(venv) sadmin@debian:~/workspace/selfish$ cp selfish/static/js/main/index.js selfish/static/js/auth/auth.js
(venv) sadmin@debian:~/workspace/selfish$ 

На данном этапе разработки код этих файлов абсолютно идентичен, но использовать index.js в базовом шаблоне системы авторизации не следует, потому что в процессе разработки index.js получит новый код, который страницам системы авторизации не нужен, поэтому новый файл. Обновляю страницу в браузере.

login-page

На странице появилась дата и цифровые часы.

В этом же шаблоне дописываю в блоке styles новые файлы с таблицами стилей.

{% block styles %}
  {{ super() }}
  {% assets filters='cssmin', output='generic/css/auth.css',
            'css/base.css', 'css/content-block.css',
            'css/auth/form-block.css' %}
    <link rel="stylesheet" type="text/css" href="{{ ASSET_URL }}">
  {% endassets %}
{% endblock styles %}

Создаю эти файлы.

(venv) sadmin@debian:~/workspace/selfish$ mkdir selfish/static/css/auth
(venv) sadmin@debian:~/workspace/selfish$ touch selfish/static/css/content-block.css
(venv) sadmin@debian:~/workspace/selfish$ touch selfish/static/css/auth/form-block.css
(venv) sadmin@debian:~/workspace/selfish$ 

Начинаю с файла selfish/static/css/content-block.css, открываю его в редакторе и пишу в него следующий код.

.content-block {
  border-radius: 3px;
  border: 1px solid #d3ddd3;
  box-shadow: 0 0 6px #f1dbc2;
}

.block-header {
  border-radius: 3px;
  background: linear-gradient(to bottom, #fffef2, #f3f2e7);
  padding: 10px;
}

.block-body {
  margin: 8px 12px 12px;
}

.today-field {
  font-size: 0.85em;
  font-style: italic;
  font-weight: 600;
}

.form-help {
  font-style: italic;
  text-align: justify;
  margin: 6px 0;
}

.form-help p {
  margin: 2px 0 0;
}

.next-block {
  margin-top: 6px;
}

Здесь следует отметить, что эти таблицы стилей я буду использовать в процессе разработки в других шаблонах много раз, поэтому я выделил их в отдельный файл, это даст мне возможность менять внешний вид некоторых элементов многих страниц правкой одного единственного файла.

Обновляю страницу в браузере.

login-page

Открываю файл selfish/static/css/auth/form-block.css и пишу в него следующее.

.form-block {
  margin: 20px 0 20px 0;
}

.form-form {
  margin: 0;
}

.form-group {
  margin: 6px 0 0;
}

.form-label {
  width: 25%;
  float: left;
  padding-top: 7px;
}

.checkbox {
  margin: 0;
}

.checkbox label label {
  padding-left: 0;
}

.form-input {
  margin-left: 26%;
}

Обновляю страницу в браузере.

login-page

Прежде чем тестировать страницу, мне необходимо добавить пару полезных ссылок в базовый шаблон. Об этом далее.

15. Дополнение базового шаблона Selfish

Чтобы предоставить пользователям сервиса Selfish возможность попадать на страницу входа в сервис из главного меню приложения, я дополню базовый шаблон необходимыми ссылками. Открываю в редакторе файл selfish/templates/base.html, нахожу в этом файле следующий блок.

          <ul class="nav navbar-nav navbar-right">
            <li class="dropdown">
              <a href="#" class="dropdown-toggle" data-toggle="dropdown">
                Действия <b class="caret"></b>
              </a>
              <ul class="dropdown-menu">
                <li><a href="">Войти</a></li>
                <li><a href="">Зарегистрироваться</a></li>
              </ul>
            </li>
          </ul>

Напомню, он находится внутри тэга nav. Привожу этот блок к следующему виду.

          <ul class="nav navbar-nav navbar-right">
            <li class="dropdown">
              {% if current_user.is_authenticated %}
              <a href="#" class="dropdown-toggle" data-toggle="dropdown">
                <img alt="avatar"
                     src="{{ current_user.account.get_ava_url(size=22) }}">
                <span
                  class="current-user-name">{{ current_user.username }}</span>
                <b class="caret"></b>
              </a>
              {% else %}
              <a href="#" class="dropdown-toggle" data-toggle="dropdown">
                Действия <b class="caret"></b>
              </a>
              {%  endif %}
              <ul class="dropdown-menu">
                {% if current_user.is_authenticated %}
                <li><a href="{{ url_for('auth.logout') }}">Выйти</a></li>
                <li><a href="">Профиль</a></li>
                {% else %}
                <li><a href="{{ url_for('auth.login') }}">Войти</a></li>
                <li><a href="">Зарегистрироваться</a></li>
                {% endif %}
              </ul>
            </li>
          </ul>

Открываю файл selfish/static/css/base.css и дописываю в конец файла следующий код.

.current-user-name {
  padding: 0 0 0 3px;
}

Это последний штрих. Всё готово к первому тестированию Selfish в браузере.

16. Тестирование в браузере

Настал момент истины. Стучусь в браузере в url главной страницы.

index-page

В главном меню нажимаю ссылку "Действия" и в выпавшем списке выбираю пункт "Войти".

index-page-menu

Жму и оказываюсь на странице входа в сервис.

login-page

Жму на кнопку "Войти в сервис", при этом поля Логин и Пароль оставляю пустыми.

login-errors

Ввожу в поле логин адрес первого зарегистрированного аккаунта, который не имеет пользователя, ввожу любой пароль и жму кнопку "Войти в сервис".

bad-login

bad-login-result

Совершенно естественно, что программа не пустила в сервис с такими данными. Но здесь обнаружился небольшой изъян. Дело в том, что когда страница после неудачной попытки войти отрендерилась заново, поле формы Логин не очистилось и осталось заполненным. Это легко исправить. Открываю в редакторе файл selfish/auth/views.py и в функции представления login в самом её конце дописываю одну строчку.

@auth.route('/login', methods=['GET', 'POST'])
def login():
    ...
    form.login.data = ''
    return render_template('auth/login.html', form=form)

Возвращаюсь в браузер и повторяю процедуру, убеждаюсь, что теперь поле Логин очищается.

bad-login-result

Ввожу адрес почты зарегистрированного пользователя и его действительный пароль.

login-again

Жму на кнопку и оказываюсь в сервисе. Обращаю внимание, как изменилось главное меню, в нём появился аватар пользователя и его псевдоним.

good-login

Если нажать на этот пункт главного меню, появится выпадающий список.

main-menu

Жму "Выйти". Оказываюсь на главной странице анонимным пользователем.

Возвращаюсь на страницу входа в сервис и пытаюсь ввести в поле Логин псевдоним пользователя.

login-with-username

Убеждаюсь, что программа пускает в сервис и в этом случае.

17. Сохранение изменений в git

Этот этап разработки завершен, нужно сохранить изменения в git. Для этого сохраняю изменения в файле requirements.txt.

(venv) sadmin@debian:~/workspace/selfish$ pip freeze > requirements.txt 
(venv) sadmin@debian:~/workspace/selfish$ 

Копирую файл config.py.

(venv) sadmin@debian:~/workspace/selfish$ cp config.py config.template.py 
(venv) sadmin@debian:~/workspace/selfish$ 

Затем добавляю изменения в репозиторий.

(venv) sadmin@debian:~/workspace/selfish$ git add .
(venv) sadmin@debian:~/workspace/selfish$ 

Ещё раз просматриваю все изменения при помощи git status и git diff --staged. Затем делаю коммит.

(venv) sadmin@debian:~/workspace/selfish$ git commit -m"Create the login page"
[master 1e4d18d] Create the login page
 27 files changed, 676 insertions(+), 8 deletions(-)
 create mode 100755 migrations/README
 create mode 100644 migrations/alembic.ini
 create mode 100755 migrations/env.py
 create mode 100755 migrations/script.py.mako
 create mode 100644 migrations/versions/2cb33e363edc_create_account.py
 create mode 100644 migrations/versions/d1993d37afb0_create_user.py
 create mode 100644 requirements-first.txt
 create mode 100644 selfish/auth/__init__.py
 create mode 100644 selfish/auth/forms.py
 create mode 100644 selfish/auth/views.py
 create mode 100644 selfish/models/__init__.py
 create mode 100644 selfish/models/auth.py
 create mode 100644 selfish/models/exc.py
 create mode 100644 selfish/static/css/auth/form-block.css
 create mode 100644 selfish/static/css/content-block.css
 create mode 100644 selfish/static/js/auth/auth.js
 create mode 100644 selfish/templates/auth/auth_base.html
 create mode 100644 selfish/templates/auth/login.html
 create mode 100644 selfish/templates/macros/_auth.html
(venv) sadmin@debian:~/workspace/selfish$ 

Текущая версия кода Selfish доступна на github.com.

Продолжение следует. В следующей серии я научу Selfish различать пользователей по нескольким группам и таким образом предоставлять им разные возможности в сервисе.

Вопросы и предложения, ежели таковые имеются, можно сформулировать в комментариях, для этого существует синяя кнопка ниже, которую рекомендуется нажимать после обновления страницы. В браузере должны быть включены куки и JS.

 
Осталось: 39
Комментарии: