🐍📚 Создаем аналог LiveLib.ru на Flask. Часть 1: основы работы с SQLAlchemy

Изучаем взаимодействие Flask с SQLAlchemy и WTForms, создавая веб-приложение — лайт-версию сервиса LiveLib.ru — для хранения информации о прочитанных книгах. Реализуем CRUD, пагинацию, фильтры и экспорт данных.
🐍📚 Создаем аналог LiveLib.ru на Flask. Часть 1: основы работы с SQLAlchemy

Любое более-менее серьезное веб-приложение использует базу данных для хранения полученной от фронтенда информации. Для упрощения взаимодействия Flask-приложений с базой чаще всего используют библиотеку SQLAlchemy, а для получения и валидации данных пользователя – формы WTForms.

Обзор проекта

Это приложение для ведения списка прочитанных книг. Для каждой книги создается отдельная карточка с постером, именем автора, названием жанра, описанием сюжета, оценкой и примечаниями. Карточки можно редактировать и удалять. Весь код проекта находится здесь.

Готовое приложение AvidReader
Готовое приложение AvidReader

Что мы изучим в процессе работы

  1. Узнаем, как создать базу данных и заполнить ее тестовыми данными из json-файла.
  2. Реализуем пагинацию и набор CRUD-операций для работы с карточками книг.
  3. Напишем пользовательский валидатор и обработаем ошибку IntegrityError.
  4. Сделаем несколько фильтров для обработки определенных категорий данных.
  5. Добавим в приложение простую (без JS) систему оценки книг.
  6. Рассмотрим способы работы с объектами данных в шаблонизаторе Jinja2.

Первый этап

Прежде всего создадим директорию для проекта, активируем виртуальное окружение и установим все необходимые зависимости с помощью менеджера pipenv:

        mkdir reader
cd reader
mkdir .venv
pipenv shell
pipenv install -r requirements.txt

    

Структура готового приложения выглядит так:

        |   run.py
|   
\---reader
    |   database.db
    |   forms.py
    |   models.py
    |   routes.py
    |   __init__.py
    |   
    +---static
    |   \---css
    |           style.css
    |           
    +---templates
    |       base.html
    |       best.html
    |       book.html
    |       create.html
    |       edit.html
    |       index.html
    |       thrillers.html
    |       _formhelpers.html
    |       
    \---uploads

    

Приступаем к работе

Приведенный ниже код отвечает за создание экземпляра Flask-приложения и объекта базы данных. Сохраните его в файле /reader/__init__.py:

/reader/__init__.py
        from flask import Flask
from flask_sqlalchemy import SQLAlchemy
import os
app = Flask(__name__)
basedir = os.path.abspath(os.path.dirname(__file__))
app.config['SQLALCHEMY_DATABASE_URI'] =\
    	'sqlite:///' + os.path.join(basedir, 'database.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SECRET_KEY'] = 'hard to guess'
db = SQLAlchemy(app)

    

Примечание: если вы планируете использовать другой тип базы данных – MySQL или PostgreSQL – URI должен выглядеть так:

        mysql://username:password@host:port/database_name

postgresql://username:password@host:port/database_name
    
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека питониста»

Модель базы данных

Теперь нужно создать модель (таблицу) в базе данных для хранения информации о книгах. Для этого сохраните приведенный ниже код в файле /reader/models.py:

 /reader/models.py
        from reader import app, db
from sqlalchemy.sql import func
class Book(db.Model):
	id = db.Column(db.Integer, primary_key=True)
	title = db.Column(db.String(100), unique=True, nullable=False)
	author = db.Column(db.String(100), nullable=False)
	genre = db.Column(db.String(20), nullable=False)
	rating = db.Column(db.Integer)
	cover = db.Column(db.String(50), nullable=False, default='default.jpg')
	description = db.Column(db.Text)
	notes = db.Column(db.Text)
	created_at = db.Column(db.DateTime(timezone=True),
                           server_default=func.now())
 
	def __repr__(self):
            return f'<Book {self.title}>'

    

Значение cover по умолчанию равно default.jpgэто изображение надо заранее поместить в папку /reader/uploads/. Обратите внимание на один из атрибутов поля title, unique=True: это означает, что название книги должно быть уникальным. Если не предотвратить ввод дубликата (мы сделаем это позже во время валидации формы), работа приложения будет прервана ошибкой IntegrityError UNIQUE constraint failed.

Для запуска приложения создайте файл run.py:

run.py
        from reader import app
if __name__ == '__main__':
	app.run(host='127.0.0.1', port=8000, debug=True)

    

Все готово для создания базы данных – мы сделаем это в интерактивной консоли Flask:

        (.venv) D:\reader>set FLASK_APP=run
(.venv) D:\reader>flask shell
>>> from app import db
>>> from reader import db
>>> from reader.models import Book
>>> db.create_all()

    

Загляните в папку /reader – там появился файл базы, database.

Примечание: после создания таблицы ее структуру нельзя просто так изменить (добавить новый столбец, к примеру). Выход – воспользоваться расширением Flask-Migrate, либо, если данных в базе совсем мало и потерять их не жаль, выполнить:

        >>> db.drop_all()
>>> db.create_all()

    

Заполнение базы тестовыми данными

Интерактивная консоль позволяет добавлять записи в базу по одной:

        >>> book1 = Book(title = 'Преступление и наказание',
... 	author = 'Федор Достоевский',
... 	genre = 'драма',
... 	rating = '4',
... 	description = 'История Родиона Раскольникова.',
... 	notes = 'Невежество - мать всех преступлений.')
>>> db.session.add(book1)
>>> db.session.commit()

    

Или по нескольку сразу:

        >>> book2 = Book(title = 'Нос',
... 	author = 'Николай Гоголь',
... 	genre = 'фэнтези',
... 	rating = '5',
... 	description = 'История сбежавшего носа.',
... 	notes = 'Без носа человек - черт знает что: птица не птица, гражданин не
 гражданин, - просто возьми, да и вышвырни в окошко!')
>>> 
>>> book3 = Book(title = 'Мастер и Маргарита',
... 	author = 'Михаил Булгаков',
... 	genre = 'фэнтези',
... 	rating = '5',
... 	description = 'История о Дьяволе, искуплении и коте Бегемоте.',
... 	notes = 'Вздор! Лет через триста это пройдет.')
>>> db.session.add(book2)
>>> db.session.add(book3)
>>> db.session.commit()

    

Если сейчас выполнить запрос к базе, можно увидеть, что все три записи благополучно добавлены:

        >>> Book.query.all()
[<Book Преступление и наказание>, <Book Нос>, <Book Мастер и Маргарита>]

    

И первый, и второй способы добавления записей в базу, очевидно, занимают слишком много времени. Поэтому проще наполнить базу информацией из файла books.json:

        >>> from reader.models import Book
>>> from reader import db
>>> import json
>>> with open('books.json', encoding="utf8") as f:
...   books_json = json.load(f)
...   for book in books_json:
... 	book = Book(author=book['author'], description=book['description'], genr
e=book['genre'], rating=book['rating'], title=book['title'], notes=book['notes']
)
... 	db.session.add(book)
... 	db.session.commit()
    

В результате в базу было добавлено 7 новых записей:

        >>> Book.query.all()
[<Book Преступление и наказание>, <Book Нос>, <Book Мастер и Маргарита>, <Book М
изери>, <Book Замок Броуди>, <Book Облачный атлас>, <Book Пассажир>, <Book Голов
окружение>, <Book Террор>, <Book Мизерере>]
>>> exit()

    

Основные маршруты и шаблоны

После наполнения базы можно приступать к функциям представления и шаблонам для вывода карточек. Сначала займемся маршрутом для главной страницы. Сохраните этот код в файле /reader/routes.py:

/reader/routes.py
        from reader import app
from reader.models import Book
 
@app.route('/')
def index():
	books = Book.query.all()
	return render_template('index.html', books=books)
@app.route('/uploads/<filename>')
def send_file(filename):
	return send_from_directory(app.config['UPLOAD_FOLDER'], filename)

    

Вторая функция обеспечивает отправку изображений (обложек книг) из директории /reader/uploads. Использование этой функции необходимо потому, что по умолчанию Flask ищет изображения только в директории static (и вложенных в нее папках). Указание на пользовательскую папку для загрузки изображений нужно добавить в __init__.py:

        UPLOAD_FOLDER = 'uploads'
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER

    

Также в __init__.py надо сделать импорт модели и маршрутов:

        from reader import routes, models
    

Кроме того, для вывода записей нужны два шаблона – base.html и index.html, а также файл со стилями CSS. Поместите их, соответственно, в папки /reader/templates/ и /reader/static/css. Все готово – можно запускать приложение:

        python run.py
    

Пока что приложение выглядит так:

Пагинации и изображений пока нет
Пагинации и изображений пока нет

Оценка книги

Для вывода оценки книги используется простейший код в шаблоне, который печатает количество звездочек, соответствующее оценке в базе:

        {% set stars = book.rating | int %}
{% for n in range(stars) %}
<span class="fa fa-star checked" style="color:orange"></span>
{% endfor %}

    

Весь код, созданный на этом этапе – здесь.

Второй этап

Чтобы просматривать карточки книг, нужно сделать новый шаблон book.html, поместить файл illustration.jpg в /reader/uploads/ и добавить необходимый маршрут в routes.py:

routes.py
        @app.route('/<int:book_id>/')
def book(book_id):
	book = Book.query.get_or_404(book_id)
	return render_template('book.html', book=book) 

    

Кроме того, нужно добавить необходимую динамическую ссылку в шаблон index.html:

        <a href="{{ url_for('book', book_id=book.id)}}">Подробнее</a>
    

Карточка книги выглядит так:

Позже кнопки «Изменить» и «Удалить» станут функциональными
Позже кнопки «Изменить» и «Удалить» станут функциональными

Фильтры и работа с объектами данных

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

        books = Book.query.order_by(Book.created_at.desc()).all()
    

Так же просто можно отобрать книги по определенному автору или жанру. Сделаем выборку по жанру «триллер»:

        @app.route('/thrillers/')
def thrillers():
	books = Book.query.filter(Book.genre == 'триллер').all()
	return render_template('thrillers.html', books=books)

    

И по максимальной оценке 5:

        @app.route('/best/')
def best():
	books = Book.query.filter(Book.rating > 4).all()
	return render_template('best.html', books=books)

    

Вставьте эти функции в /readers/routes.py и добавьте в папку templates шаблоны для вывода триллеров и лучших фильмов. В шаблон base.html нужно добавить ссылки для кнопок в верхнем меню:

          <a class="btn btn-info mr-2" href="{{ url_for('thrillers') }}" role="button">Триллеры</a>
  <a class="btn btn-info mr-2" href="{{ url_for('best') }}" role="button">Лучшие</a>

    

Теперь можно посмотреть на выборку по триллерам:

Фильтр по триллерам
Фильтр по триллерам

И по лучшим книгам:

Книги с оценкой <b>5</b>
Книги с оценкой 5

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

        {{ url_for('send_file', filename=book.cover) }}
{{ url_for('book', book_id=book.id)}}

    

Кроме того, к атрибутам объекта данных можно применять фильтры Jinja2. Этот фильтр обеспечивает вывод даты добавления книги в формате день-месяц-год:

        {{ book.created_at.strftime('%d-%m-%Y') }}
    

По умолчанию же (без фильтра) дата будет выглядеть так:

        2022-06-25 14:17:53
    

Пагинация

Последнее, что мы сделаем на этом этапе – постраничный вывод карточек. Реализовать пагинацию с помощью SQLAlchemy действительно просто. В начале файла /reader/routes.py необходимо добавить импорт request, а затем изменить функцию представления для index таким образом:

/reader/routes.py
        @app.route('/')
def index():
	page = request.args.get('page', 1, type=int)
	books = Book.query.order_by(Book.created_at.desc()).paginate(page=page, per_page=4)
	return render_template('index.html', books=books)

    

В шаблон index.html нужно внести всего 2 дополнения – изменить books на books.items:

index.html 
        {% for book in books.items %}
    

И добавить вывод номеров страниц в самом низу:

index.html 
        {% for page_num in books.iter_pages(left_edge=1, right_edge=1, left_current=1, right_current=2) %}
  	{% if page_num %}
    	{% if books.page == page_num %}
      	<a class="btn btn-info mb-4" href="{{ url_for('index', page=page_num) }}">{{ page_num }}</a>
    	{% else %}
      	<a class="btn btn-outline-info mb-4" href="{{ url_for('index', page=page_num) }}">{{ page_num }}</a>
    	{% endif %}
  	{% else %}
    	...
  	{% endif %}
	{% endfor %}

    

Все готово:

Пагинация записей на главной странице
Пагинация записей на главной странице

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

Функция для best.html выглядит так:

/reader/routes.py
        @app.route('/best/')
def best():
	page = request.args.get('page', 1, type=int)
	books = Book.query.filter(Book.rating > 4).paginate(page=page, per_page=4)
	return render_template('best.html', books=books)

    

А для thrillers.html – так:

/reader/routes.py
        @app.route('/thrillers/')
def thrillers():
	page = request.args.get('page', 1, type=int)
	books = Book.query.filter(Book.genre == 'триллер').paginate(page=page, per_page=4)
	return render_template('thrillers.html', books=books)

    

Дополнения в самих шаблонах аналогичны тем, что мы уже сделали в index.html – нужно изменить books на books.items и добавить блок вывода пагинации:

best.html и thrillers.html 
        {% for page_num in books.iter_pages(left_edge=1, right_edge=1, left_current=1, right_current=2) %}
  	{% if page_num %}
    	{% if books.page == page_num %}
      	<a class="btn btn-info mb-4" href="{{ url_for('thrillers', page=page_num) }}">{{ page_num }}</a>
    	{% else %}
      	<a class="btn btn-outline-info mb-4" href="{{ url_for('thrillers', page=page_num) }}">{{ page_num }}</a>
    	{% endif %}
  	{% else %}
    	...
  	{% endif %}
	{% endfor %}

    

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

Материалы по теме

Комментарии

ВАКАНСИИ

Добавить вакансию
Разработчик C++
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ