[Перевод] Мега-Учебник Flask Глава 9: Разбивка на страницы (издание 2024)

Это девятая часть серии мега-учебника Flask, в которой я собираюсь рассказать вам, как разбивать списки записей базы данных на страницы.

Оглавление

В главе 8 я внес несколько изменений в базу данных, которые были необходимы для поддержки парадигмы «подписчиков», столь популярной в социальных сетях. Теперь, когда эта функциональность работает, я готов удалить последний элемент каркаса, который я установил в начале, — поддельные посты. В этой главе приложение начнет принимать записи в блогах от пользователей, а также размещать их на главной странице и страницах профиля в виде списка с разбиением на страницы.

Ссылки на GitHub для этой главы:  Browse,  Zip,  Diff.

Отправка сообщений в блог

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

app/forms.py: Форма для отправки в блог.

class PostForm(FlaskForm):
    post = TextAreaField('Say something', validators=[
        DataRequired(), Length(min=1, max=140)])
    submit = SubmitField('Submit')

Далее я могу добавить эту форму в шаблон для главной страницы приложения:

app/templates/index.html: Форма отправки сообщения в шаблоне главной страницы

{% extends "base.html" %}

{% block content %}
    

Hi, {{ current_user.username }}!

{{ form.hidden_tag() }}

{{ form.post.label }}
{{ form.post(cols=32, rows=4) }}
{% for error in form.post.errors %} [{{ error }}] {% endfor %}

{{ form.submit() }}

{% for post in posts %}

{{ post.author.username }} says: {{ post.body }}

{% endfor %} {% endblock %}

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

app/routes.py: Форма отправки сообщения в функции просмотра главной страницы.

from app.forms import PostForm
from app.models import Post

@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
    form = PostForm()
    if form.validate_on_submit():
        post = Post(body=form.post.data, author=current_user)
        db.session.add(post)
        db.session.commit()
        flash('Your post is now live!')
        return redirect(url_for('index'))
    posts = [
        {
            'author': {'username': 'John'},
            'body': 'Beautiful day in Portland!'
        },
        {
            'author': {'username': 'Susan'},
            'body': 'The Avengers movie was so cool!'
        }
    ]
    return render_template("index.html", title='Home Page', form=form,
                           posts=posts)

Давайте рассмотрим изменения в этой функции просмотра одно за другим:

  • Сейчас я импортирую Post и PostForm

  • Я принимаю запросы POST по обоим маршрутам, связанным с функцией просмотра index, в дополнение к запросам GET, поскольку эта функция просмотра теперь будет получать данные формы.

  • Логика обработки формы добавляет новую запись Post в базу данных.

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

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

Итак, зачем перенаправлять на ту же страницу?  Это стандартная практика, чтобы всегда отвечать переадресацией на запрос типа POST, созданный отправкой веб-формы. Это помогает уменьшить раздражение пользователей от того, как команда обновления страницы реализована в веб-браузерах. Когда вы нажимаете клавишу обновления, веб-браузер просто повторно отправляет последний запрос. Если последним запросом является запрос POST с отправкой формы, то при обновлении форма будет отправлена повторно. Поскольку это неожиданно, браузер попросит пользователя подтвердить отправку дубликата, но большинство пользователей не поймут, о чем их просит браузер. Если на запрос POST ответить перенаправлением, то браузеру дается указание отправить запрос GET на получение страницы, указанной в перенаправлении, так что теперь последний запрос больше не является запросом POST, и команда обновления работает более предсказуемым образом.

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

Отображение записей в блоге

Если вы помните, я создал пару поддельных записей в блоге, которые долгое время отображал на домашней странице. Эти поддельные объекты создаются явно в функции отображения index в виде простого списка Python:

posts = [
        {
            'author': {'username': 'John'},
            'body': 'Beautiful day in Portland!'
        },
        {
            'author': {'username': 'Susan'},
            'body': 'The Avengers movie was so cool!'
        }
    ]

Но теперь у меня есть метод following_posts() в модели User , который возвращает запрос для записей, которые данный пользователь хочет видеть. Так что теперь я могу заменить поддельные записи настоящими.:

app/routes.py: Отображение реальных записей на домашней странице.

@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
    # ...
    posts = db.session.scalars(current_user.following_posts()).all()
    return render_template("index.html", title='Home Page', form=form,
                           posts=posts)

Метод following_posts класса User возвращает объект запроса SQLAlchemy, который настроен для получения записей, которые интересуют пользователя, из базы данных. После выполнения этого запроса и вызова all() для объекта результатов переменная posts определяется списком со всеми результатами. В итоге я получаю структуру, очень похожую на структуру с поддельными публикациями, которую я использовал до сих пор. Это настолько близко, что шаблон даже не нужно менять.

Упрощение поиска пользователей для подписки

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

Я собираюсь создать новую страницу, которую назову «Explore». Эта страница будет работать как домашняя, но вместо того, чтобы показывать только записи от пользователей из подписки, на ней будет отображаться глобальный поток сообщений от всех пользователей. Вот новая функция просмотра «Explore».:

app/routes.py: Функция просмотра всех сообщений.

@app.route('/explore')
@login_required
def explore():
    query = sa.select(Post).order_by(Post.timestamp.desc())
    posts = db.session.scalars(query).all()
    return render_template('index.html', title='Explore', posts=posts)

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

Чтобы предотвратить сбой шаблона index.html при попытке отобразить несуществующую веб-форму, я собираюсь добавить условие, которое отображает форму, только если она была передана функцией просмотра:

app/templates/index.html: Делаем форму отправки сообщения в блог необязательной.

{% extends "base.html" %}

{% block content %}
    

Hi, {{ current_user.username }}!

{% if form %}
...
{% endif %} ... {% endblock %}

Я также собираюсь добавить ссылку на эту новую страницу на панели навигации, сразу после ссылки на главную страницу:

app/templates/base.html: Ссылка на страницу со всеми сообщениями в панели навигации.

        Explore

Помните вспомогательный шаблон _post.html, который я представил в главе 6 для отображения записей в блоге на странице профиля пользователя?  Это был небольшой шаблон, который был включен в шаблон страницы профиля пользователя и был записан в отдельный файл, чтобы его также можно было использовать из других шаблонов. Теперь я собираюсь внести в него небольшое улучшение, которое заключается в отображении имени пользователя автора записи в блоге в виде кликабельной ссылки:

app/templates/_post.html: Добавление ссылки на автора в сообщениях блога.

    
{{ post.author.username }} says:
{{ post.body }}

Теперь я могу использовать этот вложенный шаблон для отображения записей в блоге на главной странице и странице со всеми сообщениями:

app/templates/index.html: Используем вложенный шаблон записи в блоге.

    ...
    {% for post in posts %}
        {% include '_post.html' %}
    {% endfor %}
    ...

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

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

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

d04c0a3605edfbf2da5d9fdc44a76d08.png

Разбивка на страницы записей в блоге

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

Чтобы решить эту проблему, я собираюсь список записей разбить на страницы. Это означает, что изначально я собираюсь показывать только ограниченное количество постов одновременно и включать ссылки для перехода вперед и назад по полному списку постов. Flask-SQLAlchemy изначально поддерживает разбивку на страницы с помощью функции db.paginate(), которая работает аналогично db.session.scalars(), но со встроенным разбиением на страницы. Если, например, я хочу получить первые двадцать сообщений из подписки пользователя, я могу сделать это:

>>> query = sa.select(Post).order_by(Post.timestamp.desc())
>>> posts = db.paginate(query, page=1, per_page=20, error_out=False).items

Функция paginate может быть вызвана для любого запроса. Она принимает несколько аргументов, из которых следующие три являются наиболее значимыми:

  • page: номер страницы, начинающийся с 1

  • per_page: количество элементов на странице

  • error_out: флаг ошибки. Если установлено значение True, то при запросе страницы вне диапазона клиенту будет автоматически возвращена ошибка 404. Если значение False, то будет возвращен пустой список страниц, находящихся вне диапазона.

Возвращаемое значение из db.paginate() является объектом Pagination. Атрибут items этого объекта содержит список элементов на запрашиваемой странице. В объекте Pagination есть и другие полезные вещи, о которых я расскажу позже.

Теперь давайте подумаем о том, как я могу реализовать разбивку на страницы в функции просмотра index(). Я могу начать с добавления элемента конфигурации в приложение, который определяет, сколько элементов будет отображаться на странице.

config.py: Настройка количества записей на страницу.

class Config:
    # ...
    POSTS_PER_PAGE = 3

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

Далее мне нужно решить, как номер страницы будет включен в URL-адреса приложения. Довольно распространенным способом является использование аргумента строки запроса для указания необязательного номера страницы, по умолчанию равного 1, если он не указан. Вот несколько примеров URL-адресов, которые показывают, как я собираюсь это реализовать:

  • Страница 1, неявная:  http://localhost:5000/index

  • Страница 1, подробное описание:  http://localhost:5000/index? page=1

  • Страница 3:  http://localhost:5000/index? page=3

Для доступа к аргументам, указанным в строке запроса, я могу использовать объект Flask request.args. Вы уже видели это в главе 5, когда я реализовал URL-адреса входа пользователя в Flask-Login, которые иногда могут включать в себя аргумент next строки запроса.

Ниже вы можете увидеть, как я добавил разбивку на страницы в функциях отображения index и explore:

app/routes.py: Получение части сообщений из базы данных

@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
    # ...
    page = request.args.get('page', 1, type=int)
    posts = db.paginate(current_user.following_posts(), page=page,
                        per_page=app.config['POSTS_PER_PAGE'], error_out=False)
    return render_template('index.html', title='Home', form=form,
                           posts=posts.items)

@app.route('/explore')
@login_required
def explore():
    page = request.args.get('page', 1, type=int)
    query = sa.select(Post).order_by(Post.timestamp.desc())
    posts = db.paginate(query, page=page,
                        per_page=app.config['POSTS_PER_PAGE'], error_out=False)
    return render_template("index.html", title='Explore', posts=posts.items)

С этими изменениями два маршрута определяют номер страницы для отображения либо из аргумента page строки запроса, либо по умолчанию, равного 1, а затем используют метод paginate() для извлечения только нужной страницы результатов. Доступ к элементу конфигурации POSTS_PER_PAGE, определяющему размер страницы, осуществляется через объект app.config.

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

Продолжайте и попробуйте поддержку разбивки на страницы. Сначала убедитесь, что у вас написано более трех сообщений в блоге. Это легче увидеть на странице изучения, где показаны сообщения всех пользователей. Теперь вы увидите только три самых последних сообщения. Если вы хотите просмотреть следующие три, введите http://localhost:5000/explore? page=2 в адресной строке вашего браузера.

Навигация по страницам

Следующее изменение заключается в добавлении ссылок в нижней части списка записей блога, которые позволяют пользователям переходить на следующую и/или предыдущую страницы. Помните, я упоминал, что возвращаемое значение из вызова paginate() является объектом класса Pagination из Flask-SQLAlchemy?  До сих пор я использовал атрибут items этого объекта, который содержит список элементов, извлеченных для выбранной страницы. Но у этого объекта есть несколько других атрибутов, которые полезны при создании ссылок для разбивки на страницы.:

  • has_next: Возвращает True если после текущей страницы есть еще хотя бы одна страница

  • has_prev: Возвращает True если перед текущей страницей есть хотя бы еще одна страница

  • next_num: Возвращает номер страницы для следующей страницы

  • prev_num: Возвращает номер страницы для предыдущей страницы

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

app/routes.py: Ссылки на следующую и предыдущую страницы.

@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
    # ...
    page = request.args.get('page', 1, type=int)
    posts = db.paginate(current_user.following_posts(), page=page,
                        per_page=app.config['POSTS_PER_PAGE'], error_out=False)
    next_url = url_for('index', page=posts.next_num) \
        if posts.has_next else None
    prev_url = url_for('index', page=posts.prev_num) \
        if posts.has_prev else None
    return render_template('index.html', title='Home', form=form,
                           posts=posts.items, next_url=next_url,
                           prev_url=prev_url)

@app.route('/explore')
@login_required
def explore():
    page = request.args.get('page', 1, type=int)
    query = sa.select(Post).order_by(Post.timestamp.desc())
    posts = db.paginate(query, page=page,
                        per_page=app.config['POSTS_PER_PAGE'], error_out=False)
    next_url = url_for('explore', page=posts.next_num) \
        if posts.has_next else None
    prev_url = url_for('explore', page=posts.prev_num) \
        if posts.has_prev else None
    return render_template("index.html", title='Explore', posts=posts.items,
                           next_url=next_url, prev_url=prev_url)

Для next_url и prev_url в этих двух функциях просмотра будут установлены URL-адреса, возвращаемые функцией url_for() Flask, но только если есть страница, на которую можно перейти в этом направлении. Если текущая страница находится в одном из концов коллекции записей, то атрибуты has_next или has_prev объекта Pagination будут возвращать False, и в этом случае ссылка в этом направлении будет установлена на None.

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

Ссылки для разбивки на страницы устанавливаются в шаблон index.html, так что теперь давайте отобразим их на странице, прямо под списком записей:

app/templates/index.html: Отрисовка ссылок для разбивки на страницы в шаблоне.

    ...
    {% for post in posts %}
        {% include '_post.html' %}
    {% endfor %}
    {% if prev_url %}
    Newer posts
    {% endif %}
    {% if next_url %}
    Older posts
    {% endif %}
    ...

Это изменение добавляет две ссылки под списком публикаций на страницах index и explore. Первая ссылка помечена как «Новые записи» и указывает на предыдущую страницу (имейте в виду, что я показываю записи, отсортированные по новизне, поэтому первая страница — это страница с самым свежим контентом). Вторая ссылка помечена как «Старые записи» и указывает на следующую страницу записей. Если какая-либо из этих двух ссылок есть None, то она исключается со страницы с помощью условного обозначения.

18833c1d61bb66a79a561cd1d22bfe31.png

Разбивка на страницы на странице профиля пользователя

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

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

app/routes.py: Разбивка на страницы в функции просмотра профиля пользователя.

@app.route('/user/')
@login_required
def user(username):
    user = db.first_or_404(sa.select(User).where(User.username == username))
    page = request.args.get('page', 1, type=int)
    query = user.posts.select().order_by(Post.timestamp.desc())
    posts = db.paginate(query, page=page,
                        per_page=app.config['POSTS_PER_PAGE'],
                        error_out=False)
    next_url = url_for('user', username=user.username, page=posts.next_num) \
        if posts.has_next else None
    prev_url = url_for('user', username=user.username, page=posts.prev_num) \
        if posts.has_prev else None
    form = EmptyForm()
    return render_template('user.html', user=user, posts=posts.items,
                           next_url=next_url, prev_url=prev_url, form=form)

Чтобы получить список записей пользователя, я использую тот факт, что связь user.posts определена как связь только для записи, что означает, что у атрибута select() есть метод, который возвращает запрос для связанных объектов. Я беру этот запрос и добавляю метод order_by(), чтобы сначала получать самые свежие записи, а затем выполнять разбивку на страницы точно так же, как я делал для записей в главной страниц и странице всех сообщений. Обратите внимание, что ссылкам для разбивки на страницы, которые генерируются функцией url_for() на этой странице, нужен дополнительный аргумент username, потому что они указывают на страницу профиля пользователя, на которой это имя пользователя указано в качестве динамического компонента в URL.

Наконец, изменения в шаблоне user.html идентичны тем, которые я внес на страницу указателя:

app/templates/user.html: Ссылки для разбивки на страницы в шаблоне профиля пользователя.

    ...
    {% for post in posts %}
        {% include '_post.html' %}
    {% endfor %}
    {% if prev_url %}
    Newer posts
    {% endif %}
    {% if next_url %}
    Older posts
    {% endif %}

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

config.py: Настройка количества записей на страницу.

class Config:
    # ...
    POSTS_PER_PAGE = 25

Habrahabr.ru прочитано 8072 раза