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

Оглавление

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

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

Пересмотр отношений в базе данных

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

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

Один ко многим

Я уже использовал соотношение "один ко многим" в главе 4. Вот схема для этого соотношения:

Две сущности, связанные этим отношением, - это пользователи и публикации. Я говорю, что у пользователя много записей, а у записи есть один пользователь (или автор). Взаимосвязь представлена в базе данных с использованием внешнего ключа на стороне "многих". В приведенной выше взаимосвязи внешним ключом является поле user_id, добавленное в таблицу posts. Это поле связывает каждую публикацию с записью ее автора в таблице пользователей.

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

Многие ко многим

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

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

Представление отношения "многие ко многим" требует использования вспомогательной таблицы, называемой таблицей ассоциаций. Вот как база данных будет выглядеть для примера студентов и преподавателей:

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

Много к одному и один к одному

Отношение "многие к одному" похоже на отношение "один ко многим". Разница в том, что на это отношение смотрят со стороны "многих".

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

Представление подписчиков

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

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

Вот схема самореферентного отношения "многие ко многим", которое отслеживает подписчиков:

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

Представление модели базы данных

Давайте сначала добавим подписчиков в базу данных. Вот таблица ассоциаций followers. Убедитесь, что вы добавили его над моделью User в models.py, чтобы позже модель могла ссылаться на него.

app/models.py: Таблица ассоциаций подписчиков

followers = sa.Table(
    'followers',
    db.metadata,
    sa.Column('follower_id', sa.Integer, sa.ForeignKey('user.id'),
              primary_key=True),
    sa.Column('followed_id', sa.Integer, sa.ForeignKey('user.id'),
              primary_key=True)
)

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

Класс sa.Table из SQLAlchemy напрямую представляет таблицу базы данных. Имя таблицы задается в качестве первого аргумента. Вторым аргументом указываются метаданные, это место, где SQLAlchemy хранит информацию обо всех таблицах в базе данных. При использовании Flask-SQLAlchemy экземпляр метаданных может быть получен с помощью db.metadata. Столбцы этой таблицы являются экземплярами sa.Column, которые инициализируются именем столбца, типом и параметрами. В этой таблице ни один из внешних ключей не будет иметь уникальных значений, которые можно было бы использовать в качестве первичного ключа сами по себе, но пара внешних ключей в совокупности будет уникальной. По этой причине оба столбца помечены как первичные ключи. Это называется составной первичный ключ.

Теперь я могу определить два атрибута отношения "многие ко многим" в таблице users:

app/models.py: Отношения "Многие ко многим для подписчиков"

class User(UserMixin, db.Model):
    # ...
    following: so.WriteOnlyMapped['User'] = so.relationship(
        secondary=followers, primaryjoin=(followers.c.follower_id == id),
        secondaryjoin=(followers.c.followed_id == id),
        back_populates='followers')
    followers: so.WriteOnlyMapped['User'] = so.relationship(
        secondary=followers, primaryjoin=(followers.c.followed_id == id),
        secondaryjoin=(followers.c.follower_id == id),
        back_populates='following')

Настройка этого отношения нетривиальна. Как и для отношения posts "один ко многим", я использую функцию so.relationship для определения отношения в классе User. Но поскольку в этой взаимосвязи используется одна и та же модель с обеих сторон, оба атрибута взаимосвязи определяются вместе.

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

Обе связи определяются с помощью типа so.WriteOnlyMapped так же, как и связь posts. Давайте рассмотрим аргументы для вызова so.relationship() один за другим:

  • secondary настраивает таблицу ассоциаций, которая используется для этой связи, которую я определил прямо над этим классом.

  • primaryjoin указывает условие, которое связывает объект с ассоциативной таблицей. В связи following пользователь должен соответствовать атрибуту follower_id ассоциативной таблицы, поэтому условие отражает это. Выражение followers.c.follower_id ссылается на столбец follower_id ассоциативной таблицы. В отношениях followers роли поменялись местами, поэтому пользователь должен соответствовать столбцу followed_id.

  • secondaryjoin указывает условие, которое связывает ассоциативную таблицу с пользователем на другой стороне связи. В отношениях following пользователь должен соответствовать столбцу followed_id, а в отношениях followers пользователь должен соответствовать столбцу follower_id.

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

Изменения в базе данных необходимо записать при новой миграции базы данных:

(venv) $ flask db migrate -m "followers"
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'followers'
  Generating /home/miguel/microblog/migrations/versions/ae346256b650_followers.py ... done

(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 37f06a334dbf -> ae346256b650, followers

Добавление и удаление "подписчиков"

Благодаря ORM SQLAlchemy пользователь, следящий за другим пользователем, может быть зарегистрирован в базе данных, работающей со связями following и followers , как если бы они были списками. Например, если бы у меня было два пользователя, сохраненных в переменных user1 и user2, я мог бы заставить первого следить за вторым с помощью этого простого оператора:

user1.following.add(user2)

Чтобы перестать быть подписанным на пользователя, я мог бы сделать:

user1.following.remove(user2)

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

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

app/models.py: Добавление и удаление подписчиков

class User(UserMixin, db.Model):
    #...

    def follow(self, user):
        if not self.is_following(user):
            self.following.add(user)

    def unfollow(self, user):
        if self.is_following(user):
            self.following.remove(user)

    def is_following(self, user):
        query = self.following.select().where(User.id == user.id)
        return db.session.scalar(query) is not None

    def followers_count(self):
        query = sa.select(sa.func.count()).select_from(
            self.followers.select().subquery())
        return db.session.scalar(query)

    def following_count(self):
        query = sa.select(sa.func.count()).select_from(
            self.following.select().subquery())
        return db.session.scalar(query)

Методы follow() и unfollow() используют методы add() и remove() объекта связи, доступного только для записи, как я показал выше, но прежде чем они коснутся связи, они используют вспомогательный метод is_following(), чтобы убедиться, что запрошенное действие имеет смысл. Например, если я прошу user1 подписаться user2, но оказывается, что эта связь уже существует в базе данных, я не хочу добавлять дубликат. Та же логика может быть применена и к отмене подписки.

Метод is_following() выполняет запрос к связи following, чтобы узнать, включен ли в нее уже данный пользователь. Все отношения, доступные только для записи, имеют метод select(), который создает запрос, возвращающий все элементы отношения. В этом случае мне не нужно запрашивать все элементы, я просто ищу конкретного пользователя, поэтому я могу ограничить запрос предложением where().

Методы followers_count() и following_count() возвращают количество подписчиков и подписок для пользователя. Для этого требуется другой тип запроса, в котором записи не возвращаются, а просто их количество. В качестве аргумента метода sa.select() для этих запросов указывается функция sa.func.count() из SQLAlchemy, чтобы указать, что я хочу получить результат функции. Затем к методу select_from() добавляется запрос, который необходимо учесть. Всякий раз, когда запрос включается как часть более крупного запроса, SQLAlchemy требует преобразования внутреннего запроса в подзапрос путем вызова метода subquery().

Получение сообщений от пользователей из подписки

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

Наиболее очевидным решением является использование запроса, который возвращает список пользователей из подписки, который реализуется как user.following.select(). После выполнения этого запроса я могу запустить запрос, чтобы получить сообщения каждого из возвращенных пользователей. Когда у меня будут все публикации, я смогу объединить их в единый список и отсортировать по дате. Звучит заманчиво? Ну, не совсем.

У этого подхода есть пара проблем. Что произойдет, если пользователь будет подписан на тысячу человек? Мне нужно было бы выполнить тысячу запросов к базе данных только для сбора всех сообщений. А затем мне нужно будет объединить и отсортировать тысячи списков в памяти. В качестве второстепенной проблемы учтите, что на домашней странице приложения в конечном итоге будет реализована разбивка на страницы, поэтому на ней будут отображаться не все доступные публикации, а только первые несколько, со ссылкой для получения дополнительных, если необходимо. Если я собираюсь отображать сообщения, отсортированные по дате, как я могу узнать, какие сообщения самые последние из всех подписанных пользователей вместе взятых, если я не получу все сообщения и не отсортирую их сначала? На самом деле это ужасное решение, которое плохо масштабируется.

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

Ниже вы можете увидеть этот запрос:

app/models.py: Запрос на подписанные публикации

class User(UserMixin, db.Model):
    #...
    def following_posts(self):
        Author = so.aliased(User)
        Follower = so.aliased(User)
        return (
            sa.select(Post)
            .join(Post.author.of_type(Author))
            .join(Author.followers.of_type(Follower))
            .where(Follower.id == self.id)
            .order_by(Post.timestamp.desc())
        )

Это, безусловно, самый сложный запрос, который я использовал в этом приложении. Я попытаюсь расшифровать этот запрос по частям. Игнорируя пока два вызова so.aliased(), когда вы посмотрите на структуру этого запроса, вы заметите, что есть четыре основных раздела, определяемых двумя join(), where() и order_by():

sa.select(Post)
    .join(...)
    .join(...)
    .where(...)
    .order_by(...)

Присоединение

Чтобы понять, что делает операция объединения, давайте посмотрим на пример. Предположим, что у меня есть таблица User со следующим содержимым:

Для простоты я показываю не все поля в модели пользователя, а только те, которые важны для данного запроса.

Допустим, что в таблице ассоциаций followers указано, что пользователь john подписан на пользователей susan и david, пользователь susan подписан на mary и пользователь mary подписана на david. Приведенные выше данные следующие:

Наконец, в таблице posts содержится по одному сообщению от каждого пользователя:

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

Вот первая часть запроса, включая первый метод join(), а пока исключаем of_type(Author), о котором я расскажу позже:

sa.select(Post).join(Post.author)

В части запроса select() определяется объект, который необходимо получить, в данном случае это posts . Что я делаю дальше, так это объединяю записи в таблице posts с помощью связи Post.author.

Объединение - это операция базы данных, которая объединяет строки из двух таблиц в соответствии с заданными критериями. Объединенная таблица - это временная таблица, которая физически не существует в базе данных, но может быть использована во время запроса. Когда в качестве аргумента в методе join() указывается связь, SQLAlchemy объединяет строки из левой и правой частей связи.

С примерами данных, которые я определил выше, результат операции объединения для отношения Post.author:

Вы можете заметить, что столбцы post.user_id и user.id в объединенной таблице всегда имеют одно и то же значение. Поскольку я попросил объединить на основе отношения Post.author, которое связывает публикации с их авторами, SQLAlchemy знает, что ему нужно сопоставлять строки из таблицы posts со строками из таблицы users .

По сути, приведенное выше объединение создает расширенную таблицу, которая предоставляет доступ к публикациям вместе с информацией об авторе каждой публикации.

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

Одна из причин, почему это так сложно, заключается в том, что для этого запроса нам нужно рассматривать пользователей в двух аспектах. В приведенном выше объединении пользователи являются авторами постов, но во втором объединении мне нужно рассматривать пользователей как подписчиков других пользователей. Чтобы иметь возможность четко объяснить SQLAlchemy, как объединить все эти таблицы, мне нужно иметь способ обращаться к пользователям независимо как к авторам и как к подписчикам. Вызовы so.aliased() используются для создания двух ссылок на модель User, которые я могу использовать в запросе.

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

Author = so.aliased(User)
sa.select(Post)
    .join(Post.author.of_type(Author))

Здесь of_type(Author) определяет отношение для объединения и сообщает SQLAlchemy, что в остальной части запроса я собираюсь ссылаться на объект правой стороны отношения с псевдонимом Author.

Теперь давайте рассмотрим второе объединение в запросе:

Author = so.aliased(User)
Follower = so.aliased(User)
sa.select(Post)
    .join(Post.author.of_type(Author))
    .join(Author.followers.of_type(Follower))

Для второго объединения я хочу, чтобы SQLAlchemy присоединил на основе отношения Author.followers, при этом Author это псевдоним для User, определенный выше. Это отношение "многие ко многим", поэтому таблица ассоциаций followers также неявно должна быть частью объединения. Пользователи, добавленные в объединенную таблицу в результате этого нового объединения, будут использовать псевдоним Follower.

В связи User.followers пользователи указаны слева, что определяется внешним ключом followed_id в таблице ассоциаций, а их подписчики - справа, что определяется внешним ключом follower_id. Используя приведенный выше пример таблицы ассоциаций followers, таблица, объединяющая посты с указанием их авторов и подписчиков, представляет собой:

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

Пост с post.id == 3 появляется дважды в этой объединенной таблице. Можете сказать почему? Автор этого поста david, с user.id == 4. При поиске этого пользователя в followers таблице ассоциаций под followed_id внешним ключом есть две записи для пользователей 1 и 3, что означает, что за этим david следует john и mary. Поскольку оба пользователя должны быть присоединены к этому сообщению, написанному david, операция объединения создает две строки с этим сообщением, в каждой из которых указан один из присоединенных пользователей.

Также есть один пост, который вообще не появляется. Это post.id == 4, написано john. Согласно таблице ассоциаций followers, никто не подписан на этого пользователя, поэтому нет подписчиков, которых можно сопоставить с ним, и по этой причине объединение удаляет этот пост из результатов.

Фильтры

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

Вот часть запроса с фильтром:

    .where(Follower.id == self.id)

Поскольку этот запрос выполняется в методе класса User, поле self.id относится к тому, кто заинтересован в получении сообщений. Вызов where() выбирает элементы в объединенной таблице, подписчиком которых является этот пользователь. Помните, что псевдонимом User в этом запросе является Follower, который необходим для того, чтобы SQLAlchemy знала, на каком из двух пользователей, включенных в каждую строку таблицы, основан фильтр.

Допустим, меня интересует пользователь john, для которого в поле id установлено значение 1. Вот как выглядит объединенная таблица после фильтрации:

И это именно те посты, которые я хотел!

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

Сортировка

Последним шагом является сортировка результатов. В часть запроса, которая выполняет это:

    .order_by(Post.timestamp.desc())

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

Объединение собственных постов и пользователей, на которых подписаны

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

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

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

После рассмотрения обоих вариантов я решил выбрать второй. Ниже вы можете увидеть following_posts() метод после того, как он был расширен для включения сообщений пользователя через объединение:

app/models.py: Запрос сообщений из подписки с использованием собственных сообщений пользователя.

    def following_posts(self):
        Author = so.aliased(User)
        Follower = so.aliased(User)
        return (
            sa.select(Post)
            .join(Post.author.of_type(Author))
            .join(Author.followers.of_type(Follower), isouter=True)
            .where(sa.or_(
                Follower.id == self.id,
                Author.id == self.id,
            ))
            .group_by(Post)
            .order_by(Post.timestamp.desc())
        )

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

sa.select(Post)
    .join(...)
    .join(..., isouter=True)
    .where(sa.or_(..., ...))
    .group_by(...)
    .order_by(...)

Внешние соединения

Второе объединение теперь является внешним объединением. Вы помните, что случилось с сообщением, написанным john в предыдущем разделе? Когда было рассчитано второе присоединение, этот пост был удален, потому что у этого пользователя не было подписчиков. Чтобы иметь возможность включать собственные записи пользователя, сначала необходимо изменить соединение, чтобы сохранить сообщения, которым нет совпадений в правой части соединения. Соединения, использованные в предыдущем разделе, называются внутренними соединениями и сохраняют только записи с левой стороны, которым соответствует запись справа. Опция isouter=True указывает SQLAlchemy использовать вместо этого внешнее соединение слева, которое сохраняет элементы с левой стороны, которым нет соответствия с правой.

При использовании внешнего соединения слева объединенная таблица будет:

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

Составные фильтры

В объединенной таблице теперь есть все записи, поэтому я могу расширить метод where(), включив в него как записи от подписанных пользователей, так и собственные записи. SQLAlchemy предоставляет в помощь методы sa.or_(), sa.and_() и sa.not_() для создания сложных условий. В этом случае мне нужно использовать sa.or_() , чтобы указать, что у меня есть два варианта выбора постов.

Давайте рассмотрим обновленный фильтр:

    .where(sa.or_(
        Follower.id == self.id,
        Author.id == self.id,
    ))

Здесь я снова пользуюсь псевдонимами, без которых было бы невозможно описать то, что я хочу сделать. Что я хочу сказать с этим условием, так это то, что я хочу получать посты, подписчиком которых является пользователь, или посты, автором которых является пользователь.

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

И это идеально, так как этот список содержит два поста, на которые подписан пользователь, плюс его собственный пост.

Группировка

Вместо использования john давайте попробуем фильтровать для david:

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

Дублирование в объединенных таблицах на самом деле очень распространено. Если вы посмотрите на полную объединенную таблицу, вы также увидите, что john она дважды отображается как подписчик. Поскольку эти объединения включают отношение "многие ко многим", у обеих сторон могут получиться дубликаты, поскольку база данных сопоставляет объекты с обеих сторон.

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

Модульное тестирование модели пользователей

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

Python включает в себя очень полезный пакет unittest, который упрощает написание и выполнение модульных тестов. Давайте напишем несколько модульных тестов для существующих методов в классе User в модуле tests.py:

tests.py: Модульные тесты пользовательских моделей.

import os
os.environ['DATABASE_URL'] = 'sqlite://'

from datetime import datetime, timezone, timedelta
import unittest
from app import app, db
from app.models import User, Post


class UserModelCase(unittest.TestCase):
    def setUp(self):
        self.app_context = app.app_context()
        self.app_context.push()
        db.create_all()

    def tearDown(self):
        db.session.remove()
        db.drop_all()
        self.app_context.pop()

    def test_password_hashing(self):
        u = User(username='susan', email='susan@example.com')
        u.set_password('cat')
        self.assertFalse(u.check_password('dog'))
        self.assertTrue(u.check_password('cat'))

    def test_avatar(self):
        u = User(username='john', email='john@example.com')
        self.assertEqual(u.avatar(128), ('https://www.gravatar.com/avatar/'
                                         'd4c74594d841139328695756648b6bd6'
                                         '?d=identicon&s=128'))

    def test_follow(self):
        u1 = User(username='john', email='john@example.com')
        u2 = User(username='susan', email='susan@example.com')
        db.session.add(u1)
        db.session.add(u2)
        db.session.commit()
        following = db.session.scalars(u1.following.select()).all()
        followers = db.session.scalars(u2.followers.select()).all()
        self.assertEqual(following, [])
        self.assertEqual(followers, [])

        u1.follow(u2)
        db.session.commit()
        self.assertTrue(u1.is_following(u2))
        self.assertEqual(u1.following_count(), 1)
        self.assertEqual(u2.followers_count(), 1)
        u1_following = db.session.scalars(u1.following.select()).all()
        u2_followers = db.session.scalars(u2.followers.select()).all()
        self.assertEqual(u1_following[0].username, 'susan')
        self.assertEqual(u2_followers[0].username, 'john')

        u1.unfollow(u2)
        db.session.commit()
        self.assertFalse(u1.is_following(u2))
        self.assertEqual(u1.following_count(), 0)
        self.assertEqual(u2.followers_count(), 0)

    def test_follow_posts(self):
        # create four users
        u1 = User(username='john', email='john@example.com')
        u2 = User(username='susan', email='susan@example.com')
        u3 = User(username='mary', email='mary@example.com')
        u4 = User(username='david', email='david@example.com')
        db.session.add_all([u1, u2, u3, u4])

        # create four posts
        now = datetime.now(timezone.utc)
        p1 = Post(body="post from john", author=u1,
                  timestamp=now + timedelta(seconds=1))
        p2 = Post(body="post from susan", author=u2,
                  timestamp=now + timedelta(seconds=4))
        p3 = Post(body="post from mary", author=u3,
                  timestamp=now + timedelta(seconds=3))
        p4 = Post(body="post from david", author=u4,
                  timestamp=now + timedelta(seconds=2))
        db.session.add_all([p1, p2, p3, p4])
        db.session.commit()

        # setup the followers
        u1.follow(u2)  # john follows susan
        u1.follow(u4)  # john follows david
        u2.follow(u3)  # susan follows mary
        u3.follow(u4)  # mary follows david
        db.session.commit()

        # check the following posts of each user
        f1 = db.session.scalars(u1.following_posts()).all()
        f2 = db.session.scalars(u2.following_posts()).all()
        f3 = db.session.scalars(u3.following_posts()).all()
        f4 = db.session.scalars(u4.following_posts()).all()
        self.assertEqual(f1, [p2, p4, p1])
        self.assertEqual(f2, [p2, p3])
        self.assertEqual(f3, [p3, p4])
        self.assertEqual(f4, [p4])


if __name__ == '__main__':
    unittest.main(verbosity=2)

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

Я реализовал небольшой хак, чтобы модульные тесты не использовали обычную базу данных, которую я использую для разработки. Установив для переменной окружения DATABASE_URL значение sqlite://, я изменяю конфигурацию приложения, чтобы направить SQLAlchemy на использование базы данных в памяти SQLite во время тестов. Это важно, поскольку я не хочу, чтобы тесты вносили изменения в базу данных, которую я использую сам.

Затем метод setUp() создает контекст приложения и отправляет его. Это гарантирует, что экземпляр приложения Flask вместе с его конфигурационными данными будет доступен для расширений Flask. Не волнуйтесь, если на данный момент это не имеет особого смысла, поскольку позже это будет рассмотрено более подробно.

Вызов db.create_all() создает все таблицы базы данных. Это быстрый способ создать базу данных с нуля, который полезен для тестирования. Для разработки и производственного использования я уже показал вам, как создавать таблицы базы данных с помощью миграции базы данных.

Вы можете запустить весь набор тестов с помощью следующей команды:

(venv) $ python tests.py
[2023-11-19 14:51:07,578] INFO in __init__: Microblog startup
test_avatar (__main__.UserModelCase.test_avatar) ... ok
test_follow (__main__.UserModelCase.test_follow) ... ok
test_follow_posts (__main__.UserModelCase.test_follow_posts) ... ok
test_password_hashing (__main__.UserModelCase.test_password_hashing) ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.259s

OK

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

Интеграция подписки в приложение

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

Поскольку действия "Подписаться" и "отменить подписку" вносят изменения в приложение, я собираюсь реализовать их в виде POST запросов, которые запускаются из веб-браузера в результате отправки веб-формы. Было бы проще реализовать эти маршруты в виде GET запросов, но тогда их можно было бы использовать в CSRF атаках. Потому что GET запросы сложнее защитить от CSRF, их следует использовать только для действий, которые не вносят изменений состояния. Лучше реализовать их в результате отправки формы, потому что тогда в форму можно добавить токен CSRF.

Но как можно запустить действие "подписаться" или "отменить подписку" из веб-формы, когда единственное, что нужно сделать пользователю, это нажать "Подписаться" или "Отменить подписку" без отправки каких-либо данных? Чтобы это сработало, форма будет пустой. Единственными элементами в форме будет токен CSRF, который реализован в виде скрытого поля и добавляется автоматически Flask-WTF, и кнопка отправки, которую пользователю нужно нажать, чтобы запустить действие. Поскольку два действия практически идентичны, я собираюсь использовать одну и ту же форму для обоих. Я собираюсь назвать эту форму EmptyForm.

app/forms.py: Пустая форма для подписки и отмены подписки.

class EmptyForm(FlaskForm):
    submit = SubmitField('Submit')

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

app/routes.py: Маршруты подписки и её отмены.

from app.forms import EmptyForm

# ...

@app.route('/follow/<username>', methods=['POST'])
@login_required
def follow(username):
    form = EmptyForm()
    if form.validate_on_submit():
        user = db.session.scalar(
            sa.select(User).where(User.username == username))
        if user is None:
            flash(f'User {username} not found.')
            return redirect(url_for('index'))
        if user == current_user:
            flash('You cannot follow yourself!')
            return redirect(url_for('user', username=username))
        current_user.follow(user)
        db.session.commit()
        flash(f'You are following {username}!')
        return redirect(url_for('user', username=username))
    else:
        return redirect(url_for('index'))


@app.route('/unfollow/<username>', methods=['POST'])
@login_required
def unfollow(username):
    form = EmptyForm()
    if form.validate_on_submit():
        user = db.session.scalar(
            sa.select(User).where(User.username == username))
        if user is None:
            flash(f'User {username} not found.')
            return redirect(url_for('index'))
        if user == current_user:
            flash('You cannot unfollow yourself!')
            return redirect(url_for('user', username=username))
        current_user.unfollow(user)
        db.session.commit()
        flash(f'You are not following {username}.')
        return redirect(url_for('user', username=username))
    else:
        return redirect(url_for('index'))

Обработка форм в этих маршрутах проще, потому что нам нужно реализовать только часть отправки. В отличие от других форм, таких как формы входа в систему и редактирования профиля, у этих двух форм нет собственных страниц, формы будут отображаться по маршруту user() и будут отображаться на странице профиля пользователя. Единственная причина, по которой validate_on_submit() вызов может завершиться неудачей, если токен CSRF отсутствует или недействителен, поэтому в этом случае я просто перенаправляю приложение обратно на домашнюю страницу.

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

Чтобы отобразить кнопку "Подписаться" или "Отменить подписку", мне нужно создать экземпляр объекта EmptyForm и передать его в шаблон user.html . Поскольку эти два действия являются взаимоисключающими, я могу передать единственный экземпляр этой универсальной формы в шаблон:

app/routes.py: Форма для подписки на пользователя и её отмены.

@app.route('/user/<username>')
@login_required
def user(username):
    # ...
    form = EmptyForm()
    return render_template('user.html', user=user, posts=posts, form=form)

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

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

        ...
        <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 %}
        <p>{{ user.followers_count() }} followers, {{ user.following_count() }} following.</p>
        {% if user == current_user %}
        <p><a href="{{ url_for('edit_profile') }}">Edit your profile</a></p>
        {% elif not current_user.is_following(user) %}
        <p>
            <form action="{{ url_for('follow', username=user.username) }}" method="post">
                {{ form.hidden_tag() }}
                {{ form.submit(value='Follow') }}
            </form>
        </p>
        {% else %}
        <p>
            <form action="{{ url_for('unfollow', username=user.username) }}" method="post">
                {{ form.hidden_tag() }}
                {{ form.submit(value='Unfollow') }}
            </form>
        </p>
        {% endif %}
        ...

Изменения в шаблоне профиля пользователя добавляют строку под меткой времени последней активности, которая показывает, сколько подписчиков и подписок у этого пользователя. А строка, в которой есть ссылка "Редактировать", когда вы просматриваете свой собственный профиль, теперь может содержать одну из трех возможных ссылок:

  • Если пользователь просматривает свой собственный профиль, ссылка "Редактировать" отображается, как и раньше.

  • Если пользователь просматривает пользователя, на которого в данный момент нет подписки, отображается форма "Подписаться".

  • Если пользователь просматривает пользователя, на которого в данный момент подписан, отображается форма "Отменить подписку".

Чтобы повторно использовать экземпляр EmptyForm() как для формы "Подписаться", так и для формы "Отменить подписку", я передаю аргумент value при отображении кнопки "Отправить". В кнопке "Отправить" атрибут value определяет надпись на самой кнопке, поэтому с помощью этого трюка я могу изменить текст в кнопке отправки в зависимости от действия, которое мне нужно представить пользователю.

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

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

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