Django 5 для начинающих

Прогресс по курсу:  9/1004

6.7 Извлечение постов по сходству
1 из 1 шага пройден

Извлечение постов по сходству

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

Для того чтобы получить посты, схожие с конкретным постом, необходимо выполнить следующие действия:

  1. извлечь все теги текущего поста;
  2. получить все посты, помеченные любым из этих тегов;
  3. исключить текущий пост из этого списка, чтобы не рекомендовать тот же самый пост;
  4. упорядочить результаты по числу общих тегов, которое есть у текущего поста;
  5. в случае двух или более постов с одинаковым числом тегов рекомендовать самый последний пост;
  6. ограничить запрос числом постов, которые вы хотите рекомендовать.

Эти шаги транслируются в сложный набор запросов QuerySet, который вставляется в представление post_detail.

Откройте файл views.py приложения blog и добавьте следующую ниже инструкцию импорта в верхнюю его часть:

from django.db.models import Count

Это функция агрегирования Count из Django ORM-преобразователя. Данная функция позволит выполнять агрегированный подсчет тегов. Модуль django.db.models содержит следующие ниже функции агрегирования:

  • Avg: среднее значение;
  • Max: максимальное значение;
  • Min: минимальное значение;
  • Count: общее количество объектов. 

Об агрегировании можно узнать на странице https://docs.djangoproject.com/en/5.0/topics/db/aggregation/.

Откройте файл views.py приложения blog и добавьте следующие ниже строки в представление post_detail:

def post_detail(request, year, month, day, post):
    post = get_object_or_404(Post,
                             status=Post.Status.PUBLISHED,
                             slug=post,
                             publish__year=year,
                             publish__month=month,
                             publish__day=day)

    # Список активных комментариев к этому посту
    comments = post.comments.filter(active=True)

    # Форма для комментариев пользователей
    form = CommentForm()

    # Список схожих постов
    post_tags_ids = post.tags.values_list('id', flat=True)
    similar_posts = Post.published.filter(tags__in=post_tags_ids) \
        .exclude(id=post.id)
    similar_posts = similar_posts.annotate(same_tags=Count('tags')) \
                        .order_by('-same_tags', '-publish')[:4]
    return render(request,
                  'blog/post/detail.html',
                  {'post': post,
                   'comments': comments,
                   'form': form,
                   'similar_posts': similar_posts})


Приведенный выше исходный код делает следующее:

  1. извлекается Python’овский список идентификаторов тегов текущего поста. Набор запросов QuerySet values_list() возвращает кортежи со значениями заданных полей. Ему передается параметр flat=True, чтобы получить одиночные значения, такие как [1, 2, 3, ...], а не одноэлементые кортежи, такие как [(1,), (2,), (3,), ...];

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

    <QuerySet [<Post: Test post 5>, <Post: Test post 5>, <Post: Test post 4>, <Post: Test post 3>, <Post: Test post 2>, <Post: Test Post>, <Post: Test Post>]>

    Где у поста 5 и 1 по два общих тега, а у постов 2, 3, 4 только по одному совпадению.

  3. далее применяется функция агрегирования Count. Ее работа – генерировать вычисляемое поле – same_tags, – которое содержит число тегов, общих со всеми запрошенными тегами. В данном случае мы считаем общее количество одинаковых объектов, и мы можем использовать любое поле модели. В качестве примера, если мы выполним запрос: 

    similar_posts.values('title').annotate(same_tags=Count('tags'))

    То мы видим, что мы посчитали количество общих тегов, по каждому объекту: 

    <QuerySet [{'title': 'Test Post', 'same_tags': 2}, {'title': 'Test post 2', 'same_tags': 1}, {'title': 'Test post 3', 'same_tags': 1}, {'title': 'Test post 4', 'same_tags': 1}, {'title': 'Test post 5', 'same_tags': 2}]>
    
  4. и в итоге результат упорядочивается по числу общих тегов (same_tags, в убывающем порядке) и по publish, чтобы сначала отображать последние посты для постов с одинаковым числом общих тегов. Результат нарезается, чтобы получить только первые четыре поста;

  5. объект similar_posts передается в контекстный словарь для функции render().

Теперь отредактируйте шаблон blog/post/detail.html, добавив следующий ниже исходный код:

{% extends "blog/base.html" %}

{% block title %}{{ post.title }}{% endblock %}

{% block content %}
    <h1>{{ post.title }}</h1>
    <p class="date">
        Published {{ post.publish }} by {{ post.author }}
    </p>
    {{ post.body|linebreaks }}
    <p>
        <a href="{% url 'blog:post_share' post.id %}">
            Share this post
        </a>
    </p>
    <h2>Similar posts</h2>
    {% for post in similar_posts %}
        <p>
            <a href="{{ post.get_absolute_url }}">{{ post.title }}</a>
        </p>
    {% empty %}
        There are no similar posts yet.
    {% endfor %}

    {% with comments.count as total_comments %}
        <h2>
            {{ total_comments }} comment{{ total_comments|pluralize }}
        </h2>
    {% endwith %}
    {% for comment in comments %}
        <div class="comment">
            <p class="info">
                Comment {{ forloop.counter }} by {{ comment.name }}
                {{ comment.created }}
            </p>
            {{ comment.body|linebreaks }}
        </div>
    {% empty %}
        <p>There are no comments.</p>
    {% endfor %}
    {% include "blog/post/includes/comment_form.html" %}
{% endblock %}

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

Пройдите по URL-адресу http://127.0.0.1:8000/admin/blog/post/ в своем браузере, отредактируйте пост, у которого нет тегов, добавив теги music и jazz, как показано ниже:

Отредактируйте еще один пост, добавив тег jazz, как показано ниже.

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

Посты, рекомендованные в разделе Similar posts (Схожие посты), появляются в убывающем порядке в зависимости от числа общих тегов с изначальным постом.

Теперь появилась возможность успешно рекомендовать читателям схожие посты. Приложение django-taggit также содержит менеджер similar_objects(), который можно использовать для извлечения объектов на основе общих тегов.

Со всеми менеджерами приложения django-taggit можно ознакомиться на странице: https://django-taggit.readthedocs.io/en/latest/api.html.

Кроме того, список тегов можно добавить и в шаблон детальной информации о посте, точно таким же образом, как это было сделано в шаблоне blog/post/list.html.


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

Можете подробнее объяснить, что происходит под капотом в выражении similar_posts = Post.published.filter(tags__in=post_tags_ids). У нас ведь tags это экземпляр класса TaggableManager и мы проверяем, содержится ли он в списке тегов. Как экземпляр может сравниваться с числами, которые формирует post_tags_ids? И если тегов много, то проверка пройдет только лишь в том случае, когда все теги совпадают или как? Что-то я этот момент не улавливаю(

Изменен Кислинский Роман

@Кислинский_Роман

В SQL мы обычно используем оператор IN для указания нескольких значений в предложении Where. Точно так же в Django мы можем использовать поиск через оператор « in », чтобы выбрать объекты, содержащие значения, которые есть в данной итерации. Мы используем фильтр через __in. Мы получим все записи, которые содержат любые из этих тегов, исключая текущую запись.

Подробнее почитайте https://docs.djangoproject.com/en/4.2/ref/models/querysets/#in

И также про фильтрацию библиотеки - https://django-taggit.readthedocs.io/en/latest/api.html#filtering

@Илья_Перминов, Это понятно, но у нас ведь отношение многие ко многим, следовательно у поста может быть несколько тегов. Каким образом мы проверяем несколько тегов одного поста через оператор "in"?

@Кислинский_Роман, post_tags_ids нам возвращает, например <QuerySet [2, 3]>. Это ID тегов данного поста. А далее мы берем все посты блога и фильтруем на совпадение по списку с id тегов. Согласно документации мы спокойно можем это делать.

>>> Food.objects.filter(tags__name__in=["delicious", "red"])
[<Food: apple>, <Food: apple>]
    similar_posts = similar_posts.annotate(same_tags=Count('tags')).order_by('-same_tags', '-publish')[:4]

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

@Аскер_Молов, Мы же извлекаем их выше.

similar_posts = Post.published.filter(tags__in=post_tags_ids) \
        .exclude(id=post.id)
similar_posts = similar_posts.annotate(same_tags=Count('tags')) \
                        .order_by('-same_tags', '-publish')[:4]

Например мы просматриваем пост с именем Test post 6, первые 2  строки у кода выше, нам выдадут такой результат.

<QuerySet [<Post: Test post 5>, <Post: Test post 4>, <Post: Test post 3>, <Post: Test post 2>, <Post: Test Post>, <Post: Test Post>]>

Это выведет нам все посты с такими же тегами, но убрав оттуда Test post 6. Запись Test Post у нас повторяется 2 раза, так как у нее и у записи Test post 6 совпадение по двум тегам.

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

Надеюсь понятно написал))

@Илья_Перминов, Т.е для каждого совпадения по тегам один и тот же пост возвращается несколько раз?

@Аскер_Молов, Да.

Если взять пример выше, то у Test post 6 два тега - abc и xyz. У всех постов, кроме Test Post, есть совпадение только по abc или xyz. А вот уже у Test Post совпадение по двум тегам, это abc и xyz. Поэтому в первом запросе у нас будет два одинаковых Test Post в этом списке.

@Илья_Перминов, хочу дополнить все-таки, что теги мы получаем строкой выше
post_tags_ids = post.tags.values_list('id', flat=True) и уже дальше с фильтрацией по постам они попадают в переменную similar_posts, где мы можем их агрегировать (посчитать).

@Илья_Перминов, Исходя из вашей логики, получается, что метод Count в данном случае подсчитывает количество объектов в QuerySet. Если я правильно понимаю ваше утверждение, то важно отметить, что при использовании Count не имеет значения, какое поле мы указываем. Например, если мы указываем поле «body», метод просто подсчитывает количество объектов в QuerySet независимо от указанного поля.

@Никита_Айзиков, Вы абсолютно правы, мы считаем только количество объектов в QuerySet. И по какому полю считать, нам все равно. Поправил лекцию, постарался получше раскрыть этот кусок кода.

Изменен Илья Перминов

извините, что туплю, но ведь в этой строке similar_posts = Post.published.filter(tags__in=post_tags_ids) мы создаем QuerySet на основе idшников, но я никак не пойму что хранится в tags, там хранятся слаги, айдишнки или что

@Шамбер_Егор, tags это вообще по факту объект Tag и как происходит поиск сначала то по строкам, как здесь post_list = post_list.filter(tags__in=[tag]), то по ключам similar_posts = Post.published.filter(tags__in=post_tags_ids)  ?

Изменен Шамбер Егор

@Шамбер_Егор, или когда мы передаем какой-то объект на проверку Django автоматически делает проверку именно по ключам?

@Шамбер_Егор, у нас в модели Post есть менеджер, к которому мы можем обращаться через оператор tags. Попробуйте выполнить команду, скорее всего вам придет понимание:

a = Post.published.get(id=1)
print(a.tags.all())
print(a.tags.values())

То есть обращаясь, мы получаем все теги для этой записи. Что переменная post_tags_ids, что tag, они содержат QuerySet. Разница лишь в том, что мы передаем tag как список(tags__in=[tag]), а post_tags_ids нет (tags__in=post_tags_ids), так как values_list уже возвращает кортежи.

Я понимаю, тема сложная, не стесняйтесь принтовать разные моменты кода, это очень поможет в понимании. Можете посмотреть еще этот комментарий.

Не могу понять, почему в результате Post.published.filter(tags__in=post_tags_ids) QuerySet может содержать несколько одинаковых постов. Фильтр же должен накладывать условия на выборку из объектов Post, а не иметь возможность ее разветвить.

Посмотрите комментарии ниже в этой лекции, я расписывал довольно подробно этот момент.

Очень тяжело дается тема с тегами, не знаю почему,  думаю что забуду ее как страшный сон через недельку :D

@Dmitriy_Novozhilov, Это не важно, в любом случае когда будете делать по своему свое все будет иначе!