Любое более-менее серьезное веб-приложение использует базу данных для хранения полученной от фронтенда информации. Для упрощения взаимодействия Flask-приложений с базой чаще всего используют библиотеку SQLAlchemy, а для получения и валидации данных пользователя – формы WTForms.
Обзор проекта
Это приложение для ведения списка прочитанных книг. Для каждой книги создается отдельная карточка с постером, именем автора, названием жанра, описанием сюжета, оценкой и примечаниями. Карточки можно редактировать и удалять. Весь код проекта находится здесь.
![Готовое приложение AvidReader](https://media.proglib.io/posts/2022/06/27/4273c04424d91ece081f9a37ab71a03a.png)
Что мы изучим в процессе работы
- Узнаем, как создать базу данных и заполнить ее тестовыми данными из json-файла.
- Реализуем пагинацию и набор CRUD-операций для работы с карточками книг.
- Напишем пользовательский валидатор и обработаем ошибку IntegrityError.
- Сделаем несколько фильтров для обработки определенных категорий данных.
- Добавим в приложение простую (без JS) систему оценки книг.
- Рассмотрим способы работы с объектами данных в шаблонизаторе 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:
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:
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:
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:
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
Пока что приложение выглядит так:
![Пагинации и изображений пока нет](https://media.proglib.io/posts/2022/06/27/d49f326fb0a9451090778da936722b0f.png)
Оценка книги
Для вывода оценки книги используется простейший код в шаблоне, который печатает количество звездочек, соответствующее оценке в базе:
{% 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:
@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>
Карточка книги выглядит так:
![Позже кнопки «Изменить» и «Удалить» станут функциональными](https://media.proglib.io/posts/2022/06/27/b71e5873b0b10e62155a4876d1ee7fb5.png)
Фильтры и работа с объектами данных
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>
Теперь можно посмотреть на выборку по триллерам:
![Фильтр по триллерам](https://media.proglib.io/posts/2022/06/27/b70f172904223b487dafdf5af2900ba7.png)
И по лучшим книгам:
![Книги с оценкой <b>5</b>](https://media.proglib.io/posts/2022/06/27/fea939be610f865f1487fbe7ecd680b0.png)
Обратите внимание: шаблонизатору 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
таким образом:
@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
:
{% for book in books.items %}
И добавить вывод номеров страниц в самом низу:
{% 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 %}
Все готово:
![Пагинация записей на главной странице](https://media.proglib.io/posts/2022/06/27/ba1e713158b72208e3213f57dca11f83.png)
В шаблоны best.html и thrillers.html тоже нужно добавить пагинацию. Для этого необходимо внести изменения сначала в их функции представления, а потом и в сами шаблоны.
Функция для best.html выглядит так:
@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 – так:
@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
и добавить блок вывода пагинации:
{% 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-формате.
Комментарии