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

Оглавление

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

Ссылки на GitHub для этой главы: BrowseZipDiff.

Страница профиля пользователя

Чтобы создать страницу профиля пользователя, давайте добавим в приложение маршрут /user/<username>.

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

@app.route('/user/<username>')
@login_required
def user(username):
    user = db.first_or_404(sa.select(User).where(User.username == username))
    posts = [
        {'author': user, 'body': 'Test post #1'},
        {'author': user, 'body': 'Test post #2'}
    ]
    return render_template('user.html', user=user, posts=posts)

Декоратор @app.route, который я использовал для объявления этой функции просмотра, выглядит немного иначе, чем предыдущие. В данном случае у меня в нем есть динамический компонент, который обозначается как URL-компонент <username>, окруженный < и >. Когда маршрут имеет динамический компонент, Flask примет любой текст в этой части URL-адреса и вызовет функцию просмотра с фактическим текстом в качестве аргумента. Например, если клиентский браузер запрашивает URL /user/susan, будет вызвана функция просмотра с аргументом, для которого username установлено значение 'susan'. Эта функция просмотра будет доступна только авторизованным пользователям, поэтому я добавил декоратор @login_required из Flask-Login .

Реализация этой функции просмотра довольно проста. Сначала я пытаюсь загрузить пользователя из базы данных, используя запрос по имени пользователя. Вы уже видели ранее, что запрос к базе данных можно выполнить с помощью db.session.scalars() если вы хотите получить все результаты, или db.session.scalar() если вы хотите получить только первый результат, или None если результатов нет. В этой функции просмотра я использую вариант scalar(), предоставляемый Flask-SQLAlchemy под названием db.first_or_404(), который работает как scalar() при наличии результатов, но в случае, если результатов нет, он автоматически отправляет ошибку 404 обратно клиенту. Выполняя запрос таким образом, я избавляю себя от проверки, вернул ли запрос пользователя, потому что, когда имя пользователя не существует в базе данных, функция пользователя не вернет, и вместо этого будет вызвано исключение 404.

Если запрос к базе данных не вызывает ошибку 404, то это означает, что пользователь с указанным именем пользователя был найден. Затем я инициализирую поддельный список записей для этого пользователя и создаю новый user.html шаблон, в который я передаю объект user и список записей.

Шаблон user.html показан ниже:

app/templates/user.html: Шаблон профиля пользователя

{% extends "base.html" %}

{% block content %}
    <h1>User: {{ user.username }}</h1>
    <hr>
    {% for post in posts %}
    <p>
    {{ post.author.username }} says: <b>{{ post.body }}</b>
    </p>
    {% endfor %}
{% endblock %}

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

app/templates/base.html: Шаблон профиля пользователя

<div>
  Microblog:
  <a href="{{ url_for('index') }}">Home</a>
  {% if current_user.is_anonymous %}
  <a href="{{ url_for('login') }}">Login</a>
  {% else %}
  <a href="{{ url_for('user', username=current_user.username) }}">Profile</a>
  <a href="{{ url_for('logout') }}">Logout</a>
  {% endif %}
</div>

Единственное интересное изменение здесь - это вызов url_for(), который используется для генерации ссылки на страницу профиля. Поскольку функция просмотра профиля пользователя принимает динамический аргумент, функция url_for() получает значение для этой части URL в качестве аргумента ключевого слова. Поскольку это ссылка, указывающая на профиль вошедшего в систему пользователя, я могу использовать current_user Flask-Login для генерации правильного URL.

Попробуйте приложение прямо сейчас. Нажав на ссылку Profile вверху, вы попадете на вашу собственную страницу пользователя. На данном этапе нет ссылок, которые приведут вас на страницу профиля других пользователей, но если вы хотите получить доступ к этим страницам, вы можете ввести URL вручную в адресной строке браузера. Например, если в вашем приложении зарегистрирован пользователь по имени "john", вы можете просмотреть соответствующий профиль пользователя, набрав http://localhost:5000/user/john в адресной строке.

Аватары

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

Сервис Gravatar очень прост в использовании. Чтобы запросить изображение для данного пользователя, необходимо использовать URL в формате https://www.gravatar.com/avatar /<хэш>, где <hash> - хэш MD5 адреса электронной почты пользователя. Ниже вы можете увидеть, как получить URL Gravatar для пользователя с электронной почтой john@example.com:

>>> from hashlib import md5
>>> 'https://www.gravatar.com/avatar/' + md5(b'john@example.com').hexdigest()
'https://www.gravatar.com/avatar/d4c74594d841139328695756648b6bd6'

Если вы хотите увидеть реальный пример, мой собственный URL Gravatar:

https://www.gravatar.com/avatar/729e26a2a2c7ff24a71958d4aa4e5f35

Вот что возвращает Gravatar для этого URL, если вы вводите его в адресную строку браузера:

По умолчанию возвращаемый размер изображения составляет 80x80 пикселей, но можно запросить другой размер, добавив аргумент s в строку запроса URL. Например, чтобы получить свой собственный аватар в виде изображения размером 128х128 пикселей, используйте URL https://www.gravatar.com/avatar/729e26a2a2c7ff24a71958d4aa4e5f35?s=128.

Еще один интересный аргумент, который может быть передан Gravatar в качестве аргумента строки запроса, - это d, который определяет, какое изображение Gravatar предоставляет пользователям, у которых нет аватара, зарегистрированного в сервисе. Мой любимый называется "identicon", который возвращает красивый геометрический дизайн, который отличается для каждого электронной почты. Например:

Обратите внимание, что некоторые расширения веб-браузера для обеспечения конфиденциальности, такие как Ghostery, блокируют изображения Gravatar, поскольку считают, что Automattic (владельцы сервиса Gravatar) могут определять, какие сайты вы посещаете, на основе запросов, которые они получают к вашему аватару. Если вы не видите аватары в своем браузере, подумайте, что проблема может быть связана с расширением, которое вы установили в своем браузере.

Поскольку аватары связаны с пользователями, имеет смысл добавить логику, которая генерирует URL-адреса аватаров, в модель пользователя.

app/models.py: URL-адреса аватаров пользователей

from hashlib import md5
# ...

class User(UserMixin, db.Model):
    # ...
    def avatar(self, size):
        digest = md5(self.email.lower().encode('utf-8')).hexdigest()
        return f'https://www.gravatar.com/avatar/{digest}?d=identicon&s={size}'

Новый метод avatar() класса User возвращает URL изображения аватара пользователя, масштабированный до требуемого размера в пикселях. Для пользователей, у которых аватар не зарегистрирован, будет сгенерировано изображение "identicon". Чтобы сгенерировать хэш MD5, я сначала преобразовываю электронную почту в нижний регистр, поскольку этого требует служба Gravatar. Затем, поскольку поддержка MD5 в Python работает с байтами, а не со строками, я кодирую строку как байты, прежде чем передавать ее в хэш-функцию.

Если вы заинтересованы в изучении других вариантов, которые предлагает сервис Gravatar, посетить их сайт документации.

Следующий шаг - вставить изображения аватара в шаблон профиля пользователя:

app/templates/user.html: Аватарка пользователя в шаблоне

{% extends "base.html" %}

{% block content %}
    <table>
        <tr valign="top">
            <td><img src="{{ user.avatar(128) }}"></td>
            <td><h1>User: {{ user.username }}</h1></td>
        </tr>
    </table>
    <hr>
    {% for post in posts %}
    <p>
    {{ post.author.username }} says: <b>{{ post.body }}</b>
    </p>
    {% endfor %}
{% endblock %}

Самое приятное в создании класса User , отвечающего за возврат URL-адресов аватаров, заключается в том, что если однажды я решу, что аватары Gravatar - это не то, что я хочу, я могу просто переписать метод avatar(), позволяющий возвращать разные URL-адреса, и все шаблоны автоматически начнут показывать новые аватары.

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

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

app/templates/user.html: Аватары пользователей в сообщениях

{% extends "base.html" %}

{% block content %}
    <table>
        <tr valign="top">
            <td><img src="{{ user.avatar(128) }}"></td>
            <td><h1>User: {{ user.username }}</h1></td>
        </tr>
    </table>
    <hr>
    {% for post in posts %}
    <table>
        <tr valign="top">
            <td><img src="{{ post.author.avatar(36) }}"></td>
            <td>{{ post.author.username }} says:<br>{{ post.body }}</td>
        </tr>
    </table>
    {% endfor %}
{% endblock %}

Использование вложенных шаблонов Jinja

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

Вместо этого я собираюсь создать подшаблон, который отображает только одно сообщение, а затем я собираюсь ссылаться на него из обоих шаблонов user.html и index.html . Чтобы начать, я могу создать суб-шаблон, с помощью HTML-разметки только для одного поста. Я собираюсь назвать этот шаблон app/templates/_post.html. Префикс _ - это просто соглашение об именовании, помогающее мне распознавать, какие файлы шаблонов являются вложенными шаблонами.

app/templates/_post.html: Вложенный шаблон для публикации

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

Чтобы вызвать этот суб-шаблона из шаблона user.html я использую оператор include Jinja:

app/templates/user.html: Аватары пользователей в сообщениях

{% extends "base.html" %}

{% block content %}
    <table>
        <tr valign="top">
            <td><img src="{{ user.avatar(128) }}"></td>
            <td><h1>User: {{ user.username }}</h1></td>
        </tr>
    </table>
    <hr>
    {% for post in posts %}
        {% include '_post.html' %}
    {% endfor %}
{% endblock %}

Главная страница приложения на самом деле еще не доработана, поэтому я пока не собираюсь добавлять туда эту функциональность.

Еще интересные профили

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

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

app/models.py: Новые поля в модели пользователя

class User(UserMixin, db.Model):
    # ...
    about_me: so.Mapped[Optional[str]] = so.mapped_column(sa.String(140))
    last_seen: so.Mapped[Optional[datetime]] = so.mapped_column(
        default=lambda: datetime.now(timezone.utc))

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

(venv) $ flask db migrate -m "new fields in user model"
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added column 'user.about_me'
INFO  [alembic.autogenerate.compare] Detected added column 'user.last_seen'
  Generating migrations/versions/37f06a334dbf_new_fields_in_user_model.py ... done

Вывод команды migrate выглядит хорошо, поскольку показывает, что были обнаружены два новых поля в классе User . Теперь я могу применить это изменение к базе данных:

(venv) $ flask db upgrade
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade 780739b227a7 -> 37f06a334dbf, new fields in user model

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

На следующем шаге я собираюсь добавить эти два новых поля в шаблон профиля пользователя:

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

{% extends "base.html" %}

{% block content %}
    <table>
        <tr valign="top">
            <td><img src="{{ user.avatar(128) }}"></td>
            <td>
                <h1>User: {{ user.username }}</h1>
                {% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
                {% if user.last_seen %}<p>Last seen on: {{ user.last_seen }}</p>{% endif %}
            </td>
        </tr>
    </table>
    ...
{% endblock %}

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

Запись времени последнего присутствия пользователя

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

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

app/routes.py: Запись времени последнего посещения

from datetime import datetime, timezone

@app.before_request
def before_request():
    if current_user.is_authenticated:
        current_user.last_seen = datetime.now(timezone.utc)
        db.session.commit()

Декоратор @before_request из Flask регистрирует функцию, которая будет выполняться непосредственно перед функцией просмотра. Это чрезвычайно полезно, потому что теперь я могу вставлять код, который хочу выполнить перед любой функцией просмотра в приложении, и я могу разместить его в одном месте. Реализация просто проверяет, соответствует ли current_user пользователю вошедшему в систему, и в этом случае в поле last_seen устанавливается текущее время. Я упоминал об этом ранее, серверное приложение должно работать в единицах измерения времени, и стандартной практикой является использование часового пояса UTC. Использовать местное время системы не очень хорошая идея, потому что тогда то, что попадает в базу данных, зависит от вашего местоположения.

Последним шагом является фиксация сеанса базы данных, чтобы внесенное выше изменение было записано в базу данных. Если вам интересно, почему нет db.session.add() перед фиксацией учтите это, когда будете ссылаться на current_user. Flask-Login вызывает функцию обратного вызова пользовательского загрузчика, которая запустит запрос к базе данных, который поместит целевого пользователя в сеанс базы данных. Итак, вы можете снова добавить пользователя в эту функцию, но в этом нет необходимости, потому что он уже есть.

Если вы просмотрите страницу своего профиля после внесения этого изменения, вы увидите строку "Last seen on" со временем, очень близким к текущему. И если вы уйдете со страницы профиля, а затем вернетесь, вы увидите, что время постоянно обновляется.

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

Редактор профиля

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

app/forms.py: Форма редактора профиля

from wtforms import TextAreaField
from wtforms.validators import Length

# ...

class EditProfileForm(FlaskForm):
    username = StringField('Username', validators=[DataRequired()])
    about_me = TextAreaField('About me', validators=[Length(min=0, max=140)])
    submit = SubmitField('Submit')

В этой форме я использую новый тип поля и новый валидатор. Для поля "About me" я использую TextAreaField, представляющее собой многострочное поле, в которое пользователь может вводить текст. Для проверки этого поля я использую валидатор Length, который гарантирует, что введенный текст содержит от 0 до 140 символов, именно столько места я выделил для соответствующего поля в базе данных.

Шаблон, который отображает эту форму, показан ниже:

app/templates/edit_profile.html: Форма редактора профиля

{% extends "base.html" %}

{% block content %}
    <h1>Edit Profile</h1>
    <form action="" method="post">
        {{ form.hidden_tag() }}
        <p>
            {{ form.username.label }}<br>
            {{ form.username(size=32) }}<br>
            {% for error in form.username.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>
            {{ form.about_me.label }}<br>
            {{ form.about_me(cols=50, rows=4) }}<br>
            {% for error in form.about_me.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>{{ form.submit() }}</p>
    </form>
{% endblock %}

И, наконец, вот функция просмотра, которая связывает все воедино:

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

from app.forms import EditProfileForm

@app.route('/edit_profile', methods=['GET', 'POST'])
@login_required
def edit_profile():
    form = EditProfileForm()
    if form.validate_on_submit():
        current_user.username = form.username.data
        current_user.about_me = form.about_me.data
        db.session.commit()
        flash('Your changes have been saved.')
        return redirect(url_for('edit_profile'))
    elif request.method == 'GET':
        form.username.data = current_user.username
        form.about_me.data = current_user.about_me
    return render_template('edit_profile.html', title='Edit Profile',
                           form=form)

Эта функция просмотра обрабатывает форму немного по-другому. Если validate_on_submit() возвращает True, то я копирую данные из формы в объект user, а затем записываю объект в базу данных. Но когда validate_on_submit() возвращает False это может быть вызвано двумя разными причинами. Во-первых, это может быть потому, что браузер только что отправил запрос GET, на который мне нужно ответить, предоставив начальную версию шаблона формы. Это также может быть, когда браузер отправляет запрос POST с данными формы, но что-то в этих данных неверно. Для этой формы мне нужно рассматривать эти два случая отдельно. Когда форма запрашивается в первый раз с запросом GET, я хочу предварительно заполнить поля данными, которые хранятся в базе данных, поэтому мне нужно сделать обратное тому, что я сделал в случае отправки, и переместить данные, хранящиеся в полях пользователя, в форму, поскольку это гарантирует, что в этих полях формы хранятся текущие данные для пользователя. Но в случае ошибки проверки я не хочу ничего записывать в поля формы, потому что они уже были заполнены WTForms. Чтобы провести различие между этими двумя случаями, я проверяю request.method, который будет GET для первоначального запроса и POST для отправки, которая не прошла проверку.

Чтобы пользователям было проще получить доступ к странице редактора профиля, я могу добавить ссылку на страницу их профиля:

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

{% if user == current_user %}
  <p><a href="{{ url_for('edit_profile') }}">Edit your profile</a></p>
{% endif %}

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

Комментарии (0)