Django - Урок 023. Like Dislike система с помощью GenericForeignKey

Like, Django, GenericRelation, Dislike, GenericForeignKey

В статье по созданию системы закладок на Django был рассмотрен пример с использованием абстрактной модели для нескольких типов закладок, а именно для статей и комментариев к статьям. Также было акцентировано внимание на том, что поля моделей, который имели внешние ключи на различные модели, должны иметь одинаковые названия, чтобы поддерживать возможность создания единого интерфейса для добавления закладок. Это становится возможным благодаря так называемой "утиной типизации" (Duck typing) , которая подразумевает, что объекты, которые не имеют единой иерархии наследования, могут использоваться в одном и том же сценарии при наличии интерфейсов (методов) имеющих одинаковую сигнатуру.

Дословно принцип утиной типизации звучит следующим образом:

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

If it looks like a duck, swims like a duck and quacks like a duck, then it probably is a duck.

То есть имея методы с одной и той же сигнатурой, мы можем использовать объекты, не связанные иерархией наследования, в одном и том же контексте.

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

  • content_type - тип контента, к которому относится запись
  • object_id - ID записи
  • content_object - генерируемый внешний ключ на запись, по сути объект контента
  • прочие дополнительные поля

Модель данных LikeDislike с GenericForeignKey

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

from django.db import models
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey


class LikeDislike(models.Model):
    LIKE = 1
    DISLIKE = -1

    VOTES = (
        (DISLIKE, 'Не нравится'),
        (LIKE, 'Нравится')
    )

    vote = models.SmallIntegerField(verbose_name=_("Голос"), choices=VOTES)
    user = models.ForeignKey(User, verbose_name=_("Пользователь"))

    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
    object_id = models.PositiveIntegerField()
    content_object = GenericForeignKey()

    objects = LikeDislikeManager()

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

Система Like Dislike в данном случае строится на принципе +1/-1, что можно будет использовать для голосований с подсчётом суммарного рейтинга. Также имеется внешний ключ на пользователя, который проголосовал за статью или комментарий.

Для учёта типа контента используется модель ContentType , которая применяется в admin панели Django и формирует логи. Фактически при создании моделей данных автоматически создаётся и запись ContentType для этой модели. Таким образом все таблицы учитываются в модели ContentType . На этом основывается система логирования действий администратора в Django. Данный внешний ключ присваивается полю content_type.

object_id содержит ID первичного ключа экземпляра модели, для которой создаётся связь.

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

Также в модели присутствует поле objects, которому присваивается специальный менеджер модели, который облегчит работу по получению отдельно Like и Dislike счётчиков, а также их суммарного рейтинга.

LikeDislikeManager

Данный менеджер модели позволит забирать отдельно Like и Dislike записи для текущего likedislike_set статьи или комментария.

from django.db import models
from django.db.models import Sum


class LikeDislikeManager(models.Manager):
    use_for_related_fields = True

    def likes(self):
        # Забираем queryset с записями больше 0
        return self.get_queryset().filter(vote__gt=0)

    def dislikes(self):
        # Забираем queryset с записями меньше 0
        return self.get_queryset().filter(vote__lt=0)

    def sum_rating(self):
        # Забираем суммарный рейтинг
        return self.get_queryset().aggregate(Sum('vote')).get('vote__sum') or 0

Добавление связи в модели данных статей и комментариев

Добавление связей осуществляется с помощью класса GenericRelation, который в отличие от полей content_type , object_id , GenericForeignKey, не создают дополнительных миграций базы данных.

from django.db import models
from django.contrib.contenttypes.fields import GenericRelation


class Article(models.Model):
    votes = GenericRelation(LikeDislike, related_query_name='articles')


class Comment(models.Model):
    votes = GenericRelation(LikeDislike, related_query_name='comments')

В GenericRelation передано два аргумента:

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

Это означает, что при наличии related_query_name можно будет забрать голоса конкретного пользователя к статьям, или к комментариям с использованием сортироваки. А для простоты можно реализовать это в LikeDislikeManager, добавив ещё два метода.

    def articles(self):
        return self.get_queryset().filter(content_type__model='article').order_by('-articles__pub_date')

    def comments(self):
        return self.get_queryset().filter(content_type__model='comment').order_by('-comments__pub_date')

Обратите внимание на аргумент order_by. Без указания related_query_name такой аргумент выдал бы ошибку 500. А так можно забрать все лайки с сортировкой по дате публикации статей или комментариев. Для Like Dislike системы это не очень нужно, но для закладок может быть полезно.

views.py

А теперь рассмотрим представление, которое будет добавлять или удалять голос пользователя у статьи или комментария. Реализация голосования будет сделана с использованием AJAX запросов.

Суть в том, что в файле urls.py мы будем задавать для этого View модель данных, за которую голосуем, а также тип голос Like или Dislike.

Передавая запрос мы попытаемся забрать запись и если она существует, то либо установим тип голоса на противоположный, либо удалим голос. Если запись не существует, то добавим запись голоса с текущим типом Like или Dislike.

import json

from django.http import HttpResponse
from django.views import View
from django.contrib.contenttypes.models import ContentType

from ecore.models import LikeDislike


class VotesView(View):
    model = None    # Модель данных - Статьи или Комментарии
    vote_type = None # Тип комментария Like/Dislike

    def post(self, request, pk):
        obj = self.model.objects.get(pk=pk)
        # GenericForeignKey не поддерживает метод get_or_create
        try:
            likedislike = LikeDislike.objects.get(content_type=ContentType.objects.get_for_model(obj), object_id=obj.id, user=request.user)
            if likedislike.vote is not self.vote_type:
                likedislike.vote = self.vote_type
                likedislike.save(update_fields=['vote'])
                result = True
            else:
                likedislike.delete()
                result = False
        except LikeDislike.DoesNotExist:
            obj.votes.create(user=request.user, vote=self.vote_type)
            result = True

        return HttpResponse(
            json.dumps({
                "result": result,
                "like_count": obj.votes.likes().count(),
                "dislike_count": obj.votes.dislikes().count(),
                "sum_rating": obj.votes.sum_rating()
            }),
            content_type="application/json"
        )

urls.py

Запись регулярного выражения для URL определяет тип контента, за который голосуем, его первичный ключ и тип голоса. Таким образом получается 4 тип url.

from django.conf.urls import url
from django.contrib.auth.decorators import login_required

from . import views
from .models import LikeDislike
from knowledge.models import Article, Comment

app_name = 'ajax'
urlpatterns = [
    url(r'^article/(?P<pk>\d+)/like/$',
        login_required(views.VotesView.as_view(model=Article, vote_type=LikeDislike.LIKE)),
        name='article_like'),
    url(r'^article/(?P<pk>\d+)/dislike/$',
        login_required(views.VotesView.as_view(model=Article, vote_type=LikeDislike.DISLIKE)),
        name='article_dislike'),
    url(r'^comment/(?P<pk>\d+)/like/$',
        login_required(views.VotesView.as_view(model=Comment, vote_type=LikeDislike.LIKE)),
        name='comment_like'),
    url(r'^comment/(?P<pk>\d+)/dislike/$',
        login_required(views.VotesView.as_view(model=Comment, vote_type=LikeDislike.DISLIKE)),
        name='comment_dislike'),
]

html

html код в моём случае будет выглядеть следующим образом:

<ul>
    <li data-id="{{ like_obj.id }}" data-type="article" data-action="like" title="Нравится">
        <span class="glyphicon glyphicon-thumbs-up"></span>
        <span data-count="like">{{ like_obj.votes.likes.count }}</span>
    </li>
    <li data-id="{{ like_obj.id }}" data-type="article" data-action="dislike" title="Не нравится">
        <span class="glyphicon glyphicon-thumbs-down"></span>
        <span data-count="dislike">{{ like_obj.votes.dislikes.count }}</span>
    </li>
</ul>

В предыдущей статье был применён такой же принцип формирования счётчиков.

  • data-id - отвечает за pk контента, который можно добавлять в закладки.
  • data-type - тип контента, это же название фигурирует и в url.
  • data-action - действие, которое нужно совершить, в данном случае добавление в закладки
  • data-count - счётчик, показывающий сколько пользователей добавили контент в закладки

Отличие заключается в том, что для получения количества Like и Dislike используются не методы моделей данных статей и комментариев, а методы менеджера модели LikeDislikeManager , что ещё больше упрощает дальнейшую разработку, поскольку не нужно будет отслеживать, во всех ли моделях хватает методов. Достаточно будет добавить поле GenericRelation .

JavaScript

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

Покажу только сами обработчики AJAX-запросов. Их будет 2. Один для Like, второй для Dislike.

function like()
{
    var like = $(this);
    var type = like.data('type');
    var pk = like.data('id');
    var action = like.data('action');
    var dislike = like.next();

    $.ajax({
        url : "/api/" + type +"/" + pk + "/" + action + "/",
        type : 'POST',
        data : { 'obj' : pk },

        success : function (json) {
            like.find("[data-count='like']").text(json.like_count);
            dislike.find("[data-count='dislike']").text(json.dislike_count);
        }
    });

    return false;
}

function dislike()
{
    var dislike = $(this);
    var type = dislike.data('type');
    var pk = dislike.data('id');
    var action = dislike.data('action');
    var like = dislike.prev();

    $.ajax({
        url : "/api/" + type +"/" + pk + "/" + action + "/",
        type : 'POST',
        data : { 'obj' : pk },

        success : function (json) {
            dislike.find("[data-count='dislike']").text(json.dislike_count);
            like.find("[data-count='like']").text(json.like_count);
        }
    });

    return false;
}

// Подключение обработчиков
$(function() {
    $('[data-action="like"]').click(like);
    $('[data-action="dislike"]').click(dislike);
});

Для Django рекомендую VDS-сервера хостера Timeweb .

Рекомендуем хостинг TIMEWEB
Рекомендуем хостинг TIMEWEB
Стабильный хостинг, на котором располагается социальная сеть EVILEG. Для проектов на Django рекомендуем VDS хостинг.
Поддержать автора Donate
ИМ

Доброго времени суток Евгений. Не подскажете как сделать иконки (лайк-дизлайк) кликабельными?

День добрый. А в каком смысле кликабельными? Вот у меня на сайте они кликаются например. Что конкретно у вас не работает?

ИМ

В прямом, иконки не кликабельные... просто как декор висят.

А что в консоли отладчика браузера? Может у вас JavaSсript с ошибками отрабатывает?

Ещё один момент, посмотрите вот в этой статье раздел про использование CSRF токена .
Возможно, что он не используется в Cookies, тогда AJAX работать не будет.
ИМ

Дико извиняюсь. Действительно в консоли посмотрел, они кликабельные. Только сыпятся ошбки при клике на $.ajax


  1. Там ошибка 404, напрягает наличие ещё одного слеша в url вашего API
  2. Проверьте, всё ли прописали в urls.py файле. Опять же из-за ошибки 404.
l
  • #
  • 25 июня 2018 г. 8:27

really nice work .thanks.

MU

Hi, works perfect, thank you.

I try to retrieve list of users who like eg. Post, but it don't work. How to do this? I know how to retrieve list of user names but not name with avatar like on evileg.

I think, you made some mistake. Do you have likes or dislikes in admin panel? Or some more information?

Because, I don`t know, what you did in your code.

I suggest you create theme on forum in django section , and we can discuss your problem.

Здравствуйте. Ваша система очень хорошо работает.Спасибо. Но у меня есть вопрос. Допустим, проголосовал пользователь или нет, можно проверить по условию "if post.votes.user == request.user". А как опредилить какой голос пользователь поставил (лайк или дизлайк)?

Добрый день.

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

if like_dislike_object.vote == LikeDislike.LIKE:
    # ToDo Something by Like
else:
    # ToDo Something by Dislike

Ну или проверить, лайкнул ли пользователь из всех лайков так

obj.votes.likes().filter(user=request.user)

Спасибо. А не подскажите как это в шаблоне проверить?

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

Я понял. Я вам позже отвечу. У меня это реализовано, но нужно посмотреть исходники сайта.

Пока что, мне пришло в голову такое решение. Я добавил в LikeDislikeManager следующий метод:

def likers(self):
  like_dislike = self.get_queryset()
  users = []
  for item in like_dislike:
    if item.vote == 1:
      users.append(item.user)
  return users

И в шаблоне проверяю таким образом: {% if request.user in post.votes.likers %}.

я написал template tag, фильтр, через который делаю проверку прямо в шаблоне

@register.filter
def user_in(objects, user):
    if user.is_authenticated:
        return objects.filter(user=user).exists()
    return False

Для этого подгружаю модуль в шаблоне и проверяю наличие пользователя в queryset

{% load users_extras %}
<span class="mdi mdi-star mr-1 {% if obj.likes.all|user_in:user %}text-success{% endif %}"></span>

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

Спасибо большое.

Снова здравствуйте). Есть ещё вопрос. Как сохранить объект LikeDislike, при удалении статьи? Пробовал

content_type = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.SET_NULL)

Не помогло.

Добрый день!

А зачем его вообще сохранять, если статья удалена? Вообще объект LikeDislike как раз сохраняется... в этом-то и проблема связей GenericForeignKey, я бы как раз удалял это добро. А content_type - это внешний ключ на модель ContentType, то есть это будет влиять только при удалении самого типа контента статей, а не конкретной статьи.

Спасибо за помощь.

-"А зачем его вообще сохранять, если статья удалена?".

Мне нужно знать сколько лайков/дизлайков получал пользователь за все свои статьи, даже если он удалил какую-нибудь статью.

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

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

Я бы ещё добавил тогда поле target_user, куда будет добавляться пользователь, который получил лайк, тогда при удалении статьи лайк будет соотноситься с сами пользователем.

-"вообще GenericForeignKey не удаляется, если только вы там что-то не нахимичили."

Если использовать ваш код, то при удалении статьи объект LikeDislike не будет удалятся? Я правильно понял?

Просто, если это так, то очень странно. Потому что у меня LikeDislike удаляется.

да? я перепроверю. Но у меня вроде бы были проблемы с этим делом. Во всяком случае кое-какие объекты, например, теги оставляют битый мусор. Возможно в последних верссиях Джанго дело обстоит иначе уже, я начинал с Джанго 1.10.

Видимо всё из-за GenericRelation. Если его убрать со статьи, то объект LikeDislike не удаляется. Но если его убрать, то я не смогу обращаться к LikeDislike через статью. Допустим ваш template tag

@register.filter
def user_in(objects, user):
    if user.is_authenticated:
        return objects.filter(user=user).exists()
    return False
{% load users_extras %}
<span class="mdi mdi-star mr-1 {% if obj.likes.all|user_in:user %}text-success{% endif %}"></span>

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

Понятно тогда почему, вот что пишут в документации

Note also, that if you delete an object that has a GenericRelation, any objects which have a GenericForeignKey pointing at it will be deleted as well. In the example above, this means that if a Bookmark object were deleted, any TaggedItem objects pointing at it would be deleted at the same time. Unlike ForeignKey, GenericForeignKey does not accept an on_delete argument to customize this behavior; if desired, you can avoid the cascade-deletion simply by not using GenericRelation, and alternate behavior can be provided via the pre_delete signal.

Это означает, что если просто удалить объект, например, так

Article.objects.filter(id=tearget_id).delete()

То лайк, дислайк останется. Аналогичное поведение будет в том случае, если удалить статью через админку.

Если же удалять через GenericRelation, что очевидно вы и делаете, то тогда лайк дислайк удаляется.

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

Как раз таки я через админку и удалял. У меня был pre_delete signal, но я его закомментировал и попробовал снова. Результат такой же. При удалении статьи с полем GenericRelation, удаляется LikeDislike, а без этого поля не удаляется.

Видимо, придется писать какую-нибудь функцию для удаления.

Тогда я после работы посмотрю, как там обстоят дела. Дело в том, что в django-tagging как раз наоборот TaggedItem объекты остаются.

Можете пока тоже глянуть в исходники django-tagging, возможно это натолкнёт вас на идею. Я не раньше, чем часов через 8 смогу посмотреть туда.

Я перекопал все, что мог. В итоге пока пришел к тому, чтобы не использовать GenericRelation. Зато теперь все стало муторно.

Во вьюхе VoteView пришлось передалать:

LikeDislike.objects.create(content_type=content_type, vote=self.vote_type, object_id=obj.id, user=request.user, target_user=obj.user)
like_count = LikeDislike.objects.filter(content_type=content_type, object_id=obj.id, vote=LikeDislike.LIKE).count()
dislike_count = LikeDislike.objects.filter(content_type=content_type, object_id=obj.id, vote=LikeDislike.DISLIKE).count()

И пришлось ещё создать дополнительные template tag. Типо:

@register.filter
def like_in(obj, user):
    if user.is_authenticated:
        try:
            likedislike = LikeDislike.objects.get(content_type=ContentType.objects.get_for_model(obj), object_id=obj.id, user=user)
            if likedislike.vote == 1:
                return True
            return False
        except LikeDislike.DoesNotExist:
            return False
    return False

Вместо вашего user_in.

И для вывода количества лайков на статье.

@register.simple_tag
def like_count(obj):
    likedislike = LikeDislike.objects.filter(content_type=ContentType.objects.get_for_model(obj), object_id=obj.id, vote=LikeDislike.LIKE)
    return likedislike.count()

Естественно ещё и для дизлайка пришлось добавить.

Да. Знаете, я там ошибался немного по поводу GenericRelation. В Tagging это не используется, поэтому и TaggedItem остаются.

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

Большое спасибо вам за помощь в решении моих вопросов.

MU

Hi, its possible to count likes and dislikes in this app something like reddit count?

Yes, it is possible.

For summary count you have here method sum_rating() in LikeDislikeManager

obj.votes.sum_rating()

Just, need to rewrite javascript methods. Just write something like this in JS

success : function (json) {
    summary_counter.find("[data-count='counter']").text(json.sum_rating);
}
MU

It's possible to simply add vote option for non logged users?

Yes. You can use IP Address of user instead of Foreign Key of logged user.

In this article you will see, how to get IP Address from request. For field you can use GenericIPAddressField

Комментарии

Только авторизованные пользователи могут публиковать комментарии.
Пожалуйста, авторизуйтесь или зарегистрируйтесь
N
25 июня 2019 г. 14:41
Nico03

C++ - Тест 001. Первая программа и типы данных

  • Результат:40баллов,
  • Очки рейтинга-8
S
25 июня 2019 г. 9:16
SabaNtuy

C++ - Тест 004. Указатели, Массивы и Циклы

  • Результат:40баллов,
  • Очки рейтинга-8
SZ
24 июня 2019 г. 17:49
Serg Zhi

C++ - Тест 003. Условия и циклы

  • Результат:78баллов,
  • Очки рейтинга2
Последние комментарии
24 июня 2019 г. 10:23
Евгений Легоцкой

Хорошо, ну будут проблемы помимо того, что касается статей, то не стесняйтесь задавать вопросы на форуме.
МБ
24 июня 2019 г. 10:21
Михаил Булатов

Извиняюсь, все работает(из-за невнимательности).
24 июня 2019 г. 9:52
Евгений Легоцкой

Придётся делать ещё сигнал в дочернем qml и пробрасывать через коннекты и обработчики. А вообще нужно смотреть конкретный код и что вы пытаетесь сделать. Так что лучше будет, если вы зад...
21 июня 2019 г. 8:31
Ruslan Polupan

Вот моя строка по которой все отлично сработало %cqtdeployer% -bin c:/CentralMposKeys/CentalMposKeys.exe -qmake c:/Qt/5.12.2/mingw73_64/bin/qmake.exe
21 июня 2019 г. 8:24
Андрей Янкович

Возможно кому то пригодится сqtdeployer для windows работает точно так же как и для Linux разница лишь в команде запуска Linux: cqtdeployer Windows: %cqtdeployer...
Сейчас обсуждают на форуме
26 июня 2019 г. 18:10
Евгений Легоцкой

Попробуйте включить все триггеры редактирования у TableView, а потом уже немного поиграться с ними, если заработает. tableView->setEditTriggers(QAbstractItemView::AllEditTriggers);
26 июня 2019 г. 16:22
Михаиллл

Похоже никак и нужно использовать вебвью
26 июня 2019 г. 16:13
Михаиллл

Таке попробовал сделать так, но не работает вы вырубается при загрузкею.Может быть вы знаете, как это можно исправить? //import QtWebEngine 1.9import QtWebView 1.13Item { width: 4...
26 июня 2019 г. 7:55
Михаиллл

Пробовал перезапускать qmake, удалять папку билд. Не помогало. Потом перезапустил сам Qt, и помогло. Жутко глючит. Сейчас опять сталкиваюсь с подобными багами.
26 июня 2019 г. 7:07
Евгений Легоцкой

Да, работает. Но это решение подходит в основном для отоюражения статической информацию, которая не меняется. А TableView или ListView позволяют работать с моделями данных и редактировать их, ...
Ищу работу?
10,000.00 руб. - 15,000.00 руб.
Нужен помощник для создания API.
Moscow, Moscow, Russia
25,000.00 руб. - 30,000.00 руб.
Разработчик Qt/C++
Barnaul, Altai Krai, Russia

Для зарегистрированных пользователей на сайте присутствует минимальное количество рекламы

EVILEG
О нас
Услуги
Присоединяйтесь к нам
© EVILEG 2015-2019
Рекомендует хостинг TIMEWEB