From fcf4747e932e5f3aa9e78c1b8d3e2a13812c0b9d Mon Sep 17 00:00:00 2001 From: Anler Hp Date: Fri, 30 May 2014 12:43:34 +0200 Subject: [PATCH] Service for adding, removing and listing votes --- settings/common.py | 1 + taiga/projects/votes/__init__.py | 0 taiga/projects/votes/admin.py | 3 ++ taiga/projects/votes/models.py | 35 ++++++++++++ taiga/projects/votes/services.py | 92 ++++++++++++++++++++++++++++++++ taiga/projects/votes/tests.py | 3 ++ taiga/projects/votes/views.py | 3 ++ tests/factories.py | 19 +++++++ tests/integration/test_votes.py | 69 ++++++++++++++++++++++++ 9 files changed, 225 insertions(+) create mode 100644 taiga/projects/votes/__init__.py create mode 100644 taiga/projects/votes/admin.py create mode 100644 taiga/projects/votes/models.py create mode 100644 taiga/projects/votes/services.py create mode 100644 taiga/projects/votes/tests.py create mode 100644 taiga/projects/votes/views.py create mode 100644 tests/integration/test_votes.py diff --git a/settings/common.py b/settings/common.py index 05ab6f4f..75a86c55 100644 --- a/settings/common.py +++ b/settings/common.py @@ -183,6 +183,7 @@ INSTALLED_APPS = [ "taiga.projects.history", "taiga.projects.notifications", "taiga.projects.stars", + "taiga.projects.votes", "south", "reversion", diff --git a/taiga/projects/votes/__init__.py b/taiga/projects/votes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/projects/votes/admin.py b/taiga/projects/votes/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/taiga/projects/votes/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/taiga/projects/votes/models.py b/taiga/projects/votes/models.py new file mode 100644 index 00000000..6e1cf62e --- /dev/null +++ b/taiga/projects/votes/models.py @@ -0,0 +1,35 @@ +from django.conf import settings +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from django.contrib.contenttypes import generic + + +class Votes(models.Model): + content_type = models.ForeignKey("contenttypes.ContentType") + object_id = models.PositiveIntegerField() + content_object = generic.GenericForeignKey("content_type", "object_id") + count = models.PositiveIntegerField(default=0) + + class Meta: + verbose_name = _("Votes") + verbose_name_plural = _("Votes") + unique_together = ("content_type", "object_id") + + def __str__(self): + return self.count + + +class Vote(models.Model): + content_type = models.ForeignKey("contenttypes.ContentType") + object_id = models.PositiveIntegerField(null=False) + content_object = generic.GenericForeignKey("content_type", "object_id") + user = models.ForeignKey(settings.AUTH_USER_MODEL, null=False, blank=False, + related_name="votes", verbose_name=_("votes")) + + class Meta: + verbose_name = _("Vote") + verbose_name_plural = _("Votes") + unique_together = ("content_type", "object_id", "user") + + def __str__(self): + return self.user diff --git a/taiga/projects/votes/services.py b/taiga/projects/votes/services.py new file mode 100644 index 00000000..114a969c --- /dev/null +++ b/taiga/projects/votes/services.py @@ -0,0 +1,92 @@ +from django.db.models import F +from django.db.transaction import atomic +from django.db.models.loading import get_model +from django.contrib.auth import get_user_model + +from .models import Votes, Vote + + +def add_vote(obj, user): + """Add a vote to an object. + + If the user has already voted the object nothing happends, so this function can be considered + idempotent. + + :param obj: Any Django model instance. + :param user: User adding the vote. :class:`~taiga.users.models.User` instance. + """ + obj_type = get_model("contenttypes", "ContentType").objects.get_for_model(obj) + with atomic(): + _, created = Vote.objects.get_or_create(content_type=obj_type, object_id=obj.id, user=user) + + if not created: + return + + votes, _ = Votes.objects.get_or_create(content_type=obj_type, object_id=obj.id) + votes.count = F('count') + 1 + votes.save() + + +def remove_vote(obj, user): + """Remove an user vote from an object. + + If the user has not voted the object nothing happens so this function can be considered + idempotent. + + :param obj: Any Django model instance. + :param user: User removing her vote. :class:`~taiga.users.models.User` instance. + """ + obj_type = get_model("contenttypes", "ContentType").objects.get_for_model(obj) + with atomic(): + qs = Vote.objects.filter(content_type=obj_type, object_id=obj.id, user=user) + if not qs.exists(): + return + + qs.delete() + + votes, _ = Votes.objects.get_or_create(content_type=obj_type, object_id=obj.id) + votes.count = F('count') - 1 + votes.save() + + +def get_voters(obj): + """Get the voters of an object. + + :param obj: Any Django model instance. + + :return: User queryset object representing the users that voted the object. + """ + obj_type = get_model("contenttypes", "ContentType").objects.get_for_model(obj) + + return get_user_model().objects.filter(votes__content_type=obj_type, votes__object_id=obj.id) + + +def get_votes(obj): + """Get the number of votes an object has. + + :param obj: Any Django model instance. + + :return: Number of votes or `0` if the object has no votes at all. + """ + obj_type = get_model("contenttypes", "ContentType").objects.get_for_model(obj) + + try: + return Votes.objects.get(content_type=obj_type, object_id=obj.id).count + except Votes.DoesNotExist: + return 0 + + +def get_voted(user, obj_class): + """Get the objects voted by an user. + + :param user: :class:`~taiga.users.models.User` instance. + :param obj_class: Show only objects of this kind. Can be any Django model class. + + :return: + """ + obj_type = get_model("contenttypes", "ContentType").objects.get_for_model(obj_class) + conditions = ('votes_vote.content_type_id = %s', + '%s.id = votes_vote.object_id' % obj_class._meta.db_table, + 'votes_vote.user_id = %s') + return obj_class.objects.extra(where=conditions, tables=('votes_vote',), + params=(obj_type.id, user.id)) diff --git a/taiga/projects/votes/tests.py b/taiga/projects/votes/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/taiga/projects/votes/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/taiga/projects/votes/views.py b/taiga/projects/votes/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/taiga/projects/votes/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/tests/factories.py b/tests/factories.py index f43533ac..8b73abf9 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -173,3 +173,22 @@ class StarsFactory(Factory): project = factory.SubFactory("tests.factories.ProjectFactory") count = 0 + + +class VoteFactory(Factory): + FACTORY_FOR = get_model("votes", "Vote") + + content_type = factory.SubFactory("tests.factories.ContentTypeFactory") + object_id = factory.Sequence(lambda n: n) + user = factory.SubFactory("tests.factories.UserFactory") + + +class VotesFactory(Factory): + FACTORY_FOR = get_model("votes", "Votes") + + content_type = factory.SubFactory("tests.factories.ContentTypeFactory") + object_id = factory.Sequence(lambda n: n) + + +class ContentTypeFactory(Factory): + FACTORY_FOR = get_model("contenttypes", "ContentType") diff --git a/tests/integration/test_votes.py b/tests/integration/test_votes.py new file mode 100644 index 00000000..5bb5fbf6 --- /dev/null +++ b/tests/integration/test_votes.py @@ -0,0 +1,69 @@ +import pytest + +from django.contrib.contenttypes.models import ContentType + +from taiga.projects.votes import services as votes, models + +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_add_vote(): + project = f.ProjectFactory() + project_type = ContentType.objects.get_for_model(project) + user = f.UserFactory() + votes_qs = models.Votes.objects.filter(content_type=project_type, object_id=project.id) + + votes.add_vote(project, user) + + assert votes_qs.get().count == 1 + + votes.add_vote(project, user) # add_vote must be idempotent + + assert votes_qs.get().count == 1 + + +def test_remove_vote(): + user = f.UserFactory() + project = f.ProjectFactory() + project_type = ContentType.objects.get_for_model(project) + votes_qs = models.Votes.objects.filter(content_type=project_type, object_id=project.id) + f.VotesFactory(content_type=project_type, object_id=project.id, count=1) + f.VoteFactory(content_type=project_type, object_id=project.id, user=user) + + assert votes_qs.get().count == 1 + + votes.remove_vote(project, user) + + assert votes_qs.get().count == 0 + + votes.remove_vote(project, user) # remove_vote must be idempotent + + assert votes_qs.get().count == 0 + + +def test_get_votes(): + project = f.ProjectFactory() + project_type = ContentType.objects.get_for_model(project) + f.VotesFactory(content_type=project_type, object_id=project.id, count=4) + + assert votes.get_votes(project) == 4 + + +def test_get_voters(): + f.UserFactory() + project = f.ProjectFactory() + project_type = ContentType.objects.get_for_model(project) + vote = f.VoteFactory(content_type=project_type, object_id=project.id) + + assert list(votes.get_voters(project)) == [vote.user] + + +def test_get_voted(): + f.ProjectFactory() + project = f.ProjectFactory() + project_type = ContentType.objects.get_for_model(project) + vote = f.VoteFactory(content_type=project_type, object_id=project.id) + + assert list(votes.get_voted(vote.user, type(project))) == [project]