Pull to refresh

Мега-Учебник Flask, Часть IX: разбиение на страницы (издание 2018)

Reading time 12 min
Views 37K

Miguel Grinberg




<<< предыдущая следующая >>>


Это девятый выпуск серии Mega-Tutorial Flask, в котором я расскажу вам, как разбивать списки в базе данных.


Для справки ниже приведен список статей этой серии.



Примечание 1: Если вы ищете старые версии данного курса, это здесь.


Примечание 2: Если вдруг Вы хотели бы выступить в поддержку моей(Мигеля) работы в этом блоге, или просто не имеете терпения дожидаться неделю статьи, я (Мигель Гринберг)предлагаю полную версию данного руководства упакованную электронную книгу или видео. Для получения более подробной информации посетите learn.miguelgrinberg.com.


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


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


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


Представление сообщений в блогах


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


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

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


{% extends "base.html" %}

{% block content %}
    <h1>Hi, {{ current_user.username }}!</h1>
    <form action="" method="post">
        {{ form.hidden_tag() }}
        <p>
            {{ form.post.label }}<br>
            {{ form.post(cols=32, rows=4) }}<br>
            {% for error in form.post.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>{{ form.submit() }}</p>
    </form>
    {% for post in posts %}
    <p>
    {{ post.author.username }} says: <b>{{ post.body }}</b>
    </p>
    {% endfor %}
{% endblock %}

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


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 как дополнительный аргумент, так что он может отображать текстовое поле.

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


Итак, зачем redirect?



Стандартная практика — отвечать (response) на (request) запроса POST, созданного при отправке веб-формы с переадресацией. Это поможет как то избежать приступов раздражения при использовании команды обновления в веб-браузерах. Ведь когда вы нажимаете кнопку обновить, веб-браузер, выдаст последний запрос. Если запрос POST с представлением формы возвращает регулярный ответ, то обновление будет повторно отправлять форму. Поскольку это всегда неожиданно, браузер попросит пользователя подтвердить повторную отправку, но большинство пользователей не поймут, что надо браузеру.


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


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


Просмотр сообщений в блогах


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


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

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


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

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


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


Облегчить поиск пользователей


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


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


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

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


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


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


{% extends "base.html" %}

{% block content %}
    <h1>Hi, {{ current_user.username }}!</h1>
    {% if form %}
    <form action="" method="post">
        ...
    </form>
    {% endif %}
    ...
{% endblock %}

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


<a href="{{ url_for('explore') }}">Explore</a>

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


<table>
    <tr valign="top">
        <td><img src="{{ post.author.avatar(36) }}"></td>
        <td>
            <a href="{{ url_for('user', username=post.author.username) }}">
                {{ post.author.username }}
            </a>
            says:<br>{{ post.body }}
        </td>
    </tr>
</table>

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


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

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


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


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



Деление записей в блогах


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


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


>>> user.followed_posts().paginate(1, 20, False).items

Метод paginate можно вызвать для любого объекта запроса из Flask-SQLAlchemy. Это требует трех аргументов:


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

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


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


class Config(object):
    # ...
    POSTS_PER_PAGE = 3

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


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



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


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


@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
    # ...
    page = request.args.get('page', 1, type=int)
    posts = current_user.followed_posts().paginate(
        page, app.config['POSTS_PER_PAGE'], 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)
    posts = Post.query.order_by(Post.timestamp.desc()).paginate(
        page, app.config['POSTS_PER_PAGE'], False)
    return render_template("index.html", title='Explore', posts=posts.items)

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


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


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



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


  • has_next: True, если после текущей есть хотя бы одна страница
  • has_prev: True, если есть еще одна страница перед текущей
  • next_num: номер страницы для следующей страницы
  • prev_num: номер страницы для предыдущей страницы

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


@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
    # ...
    page = request.args.get('page', 1, type=int)
    posts = current_user.followed_posts().paginate(
        page, app.config['POSTS_PER_PAGE'], 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)
    posts = Post.query.order_by(Post.timestamp.desc()).paginate(
        page, app.config['POSTS_PER_PAGE'], 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(), только если есть страница в этом направлении. Если текущая страница находится на одном из концов коллекции сообщений, атрибуты has_next или has_prev объекта Pagination будут False, и в этом случае ссылка в этом направлении будет установлена ​​на None.


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


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


...
{% for post in posts %}
    {% include '_post.html' %}
{% endfor %}
{% if prev_url %}
<a href="{{ prev_url }}">Newer posts</a>
{% endif %}
{% if next_url %}
<a href="{{ next_url }}">Older posts</a>
{% endif %}
...

Это дополнение добавляет ссылку ниже списка сообщений как для главной страницы index, так и по исследуемым страницам. Первая ссылка помечена как «Новые сообщения» ("Newer posts"), и она указывает на предыдущую страницу (имейте в виду, что я показываю сообщения, отсортированные по последним данным, поэтому первая страница — с новейшим контентом).


Вторая ссылка помечена как «Старые сообщения» ("Older posts") и указывает на следующую страницу сообщений.


Если какая-либо из этих двух ссылок — None, то ссылка не будет показана на странице через условное выражение.



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


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


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


@app.route('/user/<username>')
@login_required
def user(username):
    user = User.query.filter_by(username=username).first_or_404()
    page = request.args.get('page', 1, type=int)
    posts = user.posts.order_by(Post.timestamp.desc()).paginate(
        page, app.config['POSTS_PER_PAGE'], 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
    return render_template('user.html', user=user, posts=posts.items,
                           next_url=next_url, prev_url=prev_url)

Чтобы получить список сообщений от пользователя, я воспользуюсь тем, что отношение user.posts является запросом, который уже настроен SQLAlchemy в результате определения db.relationship() в модели User. Я возьму этот запрос и добавлю order_by(), чтобы сначала получить самые новые сообщения, а затем сделаю разбивку на страницы точно так же, как я сделал для сообщений в index и explore. Обратите внимание, что ссылки на страницы, которые генерируются функцией url_for(), нуждаются в дополнительном аргументе username, поскольку они указывают на страницу профиля пользователя, которая имеет это username в качестве динамического компонента URL-адреса.


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


...
{% for post in posts %}
    {% include '_post.html' %}
{% endfor %}
{% if prev_url %}
<a href="{{ prev_url }}">Newer posts</a>
{% endif %}
{% if next_url %}
<a href="{{ next_url }}">Older posts</a>
{% endif %}

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


class Config(object):
    # ...
    POSTS_PER_PAGE = 25

<<< предыдущая следующая >>>

Tags:
Hubs:
+15
Comments 5
Comments Comments 5

Articles