From fcf4747e932e5f3aa9e78c1b8d3e2a13812c0b9d Mon Sep 17 00:00:00 2001 From: Anler Hp Date: Fri, 30 May 2014 12:43:34 +0200 Subject: [PATCH 1/3] 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] From 9923e50603fcad275383adb5b0a09f0284dc98fb Mon Sep 17 00:00:00 2001 From: Anler Hp Date: Mon, 2 Jun 2014 11:53:59 +0200 Subject: [PATCH 2/3] Generic voting application The stars application has been removed in favor of a more generic voting application that works with any model. Starring a project is just a special case of voting a project. Usage. Add a vote: votes.add_vote(, user) Remove a vote: votes.remove_vote(, user) Get the queryset of users that voted an object: votes.get_voters() Get the number of votes an object has: votes.get_votes() Get the objects of type voted by an user: votes.get_voted(user, ) The issues application is already making use of the votes application through the following urls: /api/v1/issues//upvote <- url name is "issues-upvote" /api/v1/issues//downvote <- url name is "issues-downvote" --- settings/common.py | 1 - taiga/projects/api.py | 19 +- taiga/projects/issues/api.py | 30 ++- taiga/projects/issues/serializers.py | 5 + taiga/projects/serializers.py | 17 +- taiga/projects/stars/__init__.py | 12 - taiga/projects/stars/admin.py | 3 - .../projects/stars/migrations/0001_initial.py | 248 ------------------ taiga/projects/stars/models.py | 36 --- taiga/projects/stars/services.py | 100 ------- taiga/projects/votes/__init__.py | 7 + .../projects/votes/migrations/0001_initial.py | 112 ++++++++ .../{stars => votes}/migrations/__init__.py | 0 taiga/projects/votes/serializers.py | 11 + taiga/projects/votes/services.py | 22 +- taiga/projects/votes/utils.py | 24 ++ taiga/routers.py | 2 + tests/factories.py | 22 ++ tests/integration/test_stars.py | 19 +- tests/integration/test_vote_issues.py | 68 +++++ tests/unit/test_stars.py | 91 ------- 21 files changed, 319 insertions(+), 530 deletions(-) delete mode 100644 taiga/projects/stars/__init__.py delete mode 100644 taiga/projects/stars/admin.py delete mode 100644 taiga/projects/stars/migrations/0001_initial.py delete mode 100644 taiga/projects/stars/models.py delete mode 100644 taiga/projects/stars/services.py create mode 100644 taiga/projects/votes/migrations/0001_initial.py rename taiga/projects/{stars => votes}/migrations/__init__.py (100%) create mode 100644 taiga/projects/votes/serializers.py create mode 100644 taiga/projects/votes/utils.py create mode 100644 tests/integration/test_vote_issues.py delete mode 100644 tests/unit/test_stars.py diff --git a/settings/common.py b/settings/common.py index 75a86c55..98524466 100644 --- a/settings/common.py +++ b/settings/common.py @@ -182,7 +182,6 @@ INSTALLED_APPS = [ "taiga.projects.wiki", "taiga.projects.history", "taiga.projects.notifications", - "taiga.projects.stars", "taiga.projects.votes", "south", diff --git a/taiga/projects/api.py b/taiga/projects/api.py index c249d51a..2756c65f 100644 --- a/taiga/projects/api.py +++ b/taiga/projects/api.py @@ -40,7 +40,7 @@ from . import serializers from . import models from . import permissions from . import services -from . import stars +from . import votes class ProjectAdminViewSet(ModelCrudViewSet): @@ -50,7 +50,7 @@ class ProjectAdminViewSet(ModelCrudViewSet): def get_queryset(self): qs = models.Project.objects.all() - qs = stars.attach_startscount_to_queryset(qs) + qs = votes.attach_votescount_to_queryset(qs, as_field="stars_count") return qs def pre_save(self, obj): @@ -71,7 +71,7 @@ class ProjectViewSet(ModelCrudViewSet): def get_queryset(self): qs = models.Project.objects.all() - qs = stars.attach_startscount_to_queryset(qs) + qs = votes.attach_votescount_to_queryset(qs, as_field="stars_count") qs = qs.filter(Q(owner=self.request.user) | Q(members=self.request.user)) return qs.distinct() @@ -84,13 +84,13 @@ class ProjectViewSet(ModelCrudViewSet): @detail_route(methods=['post'], permission_classes=(IsAuthenticated,)) def star(self, request, pk=None): project = self.get_object() - stars.star(project, user=request.user) + votes.add_vote(project, user=request.user) return Response(status=status.HTTP_200_OK) @detail_route(methods=['post'], permission_classes=(IsAuthenticated,)) def unstar(self, request, pk=None): project = self.get_object() - stars.unstar(project, user=request.user) + votes.remove_vote(project, user=request.user) return Response(status=status.HTTP_200_OK) @detail_route(methods=['get']) @@ -328,12 +328,13 @@ class ProjectTemplateViewSet(ModelCrudViewSet): class FansViewSet(ModelCrudViewSet): - serializer_class = serializers.FanSerializer - list_serializer_class = serializers.FanSerializer + serializer_class = votes.serializers.VoterSerializer + list_serializer_class = votes.serializers.VoterSerializer permission_classes = (IsAuthenticated,) def get_queryset(self): - return stars.get_fans(self.kwargs.get("project_id")) + project = models.Project.objects.get(pk=self.kwargs.get("project_id")) + return votes.get_voters(project) class StarredViewSet(ModelCrudViewSet): @@ -342,4 +343,4 @@ class StarredViewSet(ModelCrudViewSet): permission_classes = (IsAuthenticated,) def get_queryset(self): - return stars.get_starred(self.kwargs.get("user_id")) + return votes.get_voted(self.kwargs.get("user_id"), model=models.Project) diff --git a/taiga/projects/issues/api.py b/taiga/projects/issues/api.py index ce74a706..3f2cb8f4 100644 --- a/taiga/projects/issues/api.py +++ b/taiga/projects/issues/api.py @@ -24,11 +24,12 @@ from rest_framework import filters from taiga.base import filters from taiga.base import exceptions as exc -from taiga.base.decorators import list_route +from taiga.base.decorators import list_route, detail_route from taiga.base.api import ModelCrudViewSet from taiga.projects.mixins.notifications import NotificationSenderMixin +from .. import votes from . import models from . import permissions from . import serializers @@ -113,6 +114,11 @@ class IssueViewSet(NotificationSenderMixin, ModelCrudViewSet): update_notification_template = "update_issue_notification" destroy_notification_template = "destroy_issue_notification" + def get_queryset(self): + qs = self.model.objects.all() + qs = votes.attach_votescount_to_queryset(qs, as_field="votes_count") + return qs + def pre_save(self, obj): if not obj.id: obj.owner = self.request.user @@ -139,3 +145,25 @@ class IssueViewSet(NotificationSenderMixin, ModelCrudViewSet): if obj.type and obj.type.project != obj.project: raise exc.PermissionDenied(_("You don't have permissions for add/modify this issue.")) + + @detail_route(methods=['post'], permission_classes=(IsAuthenticated,)) + def upvote(self, request, pk=None): + issue = self.get_object() + votes.add_vote(issue, user=request.user) + return Response(status=status.HTTP_200_OK) + + @detail_route(methods=['post'], permission_classes=(IsAuthenticated,)) + def downvote(self, request, pk=None): + issue = self.get_object() + votes.remove_vote(issue, user=request.user) + return Response(status=status.HTTP_200_OK) + + +class VotersViewSet(ModelCrudViewSet): + serializer_class = votes.serializers.VoterSerializer + list_serializer_class = votes.serializers.VoterSerializer + permission_classes = (IsAuthenticated,) + + def get_queryset(self): + issue = models.Issue.objects.get(pk=self.kwargs.get("issue_id")) + return votes.get_voters(issue) diff --git a/taiga/projects/issues/serializers.py b/taiga/projects/issues/serializers.py index ec258ba8..cc98a39d 100644 --- a/taiga/projects/issues/serializers.py +++ b/taiga/projects/issues/serializers.py @@ -37,6 +37,7 @@ class IssueSerializer(WatcherValidationSerializerMixin, serializers.ModelSeriali generated_user_stories = serializers.SerializerMethodField("get_generated_user_stories") blocked_note_html = serializers.SerializerMethodField("get_blocked_note_html") description_html = serializers.SerializerMethodField("get_description_html") + votes = serializers.SerializerMethodField("get_votes_number") class Meta: model = models.Issue @@ -54,6 +55,10 @@ class IssueSerializer(WatcherValidationSerializerMixin, serializers.ModelSeriali def get_description_html(self, obj): return mdrender(obj.project, obj.description) + def get_votes_number(self, obj): + # The "votes_count" attribute is attached in the get_queryset of the viewset. + return getattr(obj, "votes_count", 0) + class IssueNeighborsSerializer(NeighborsSerializerMixin, IssueSerializer): diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index e4b73791..aeb2f868 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.py @@ -84,17 +84,16 @@ class ProjectMembershipSerializer(serializers.ModelSerializer): class ProjectSerializer(serializers.ModelSerializer): tags = PickleField(required=False) - stars = serializers.SerializerMethodField("get_starts_number") + stars = serializers.SerializerMethodField("get_stars_number") class Meta: model = models.Project read_only_fields = ("created_date", "modified_date", "owner") exclude = ("last_us_ref", "last_task_ref", "last_issue_ref") - def get_starts_number(self, obj): - # The "starts_count" attribute is attached by - # starts app service methods - return getattr(obj, "starts_count", 0) + def get_stars_number(self, obj): + # The "stars_count" attribute is attached in the get_queryset of the viewset. + return getattr(obj, "stars_count", 0) class ProjectDetailSerializer(ProjectSerializer): @@ -163,14 +162,6 @@ class ProjectTemplateSerializer(serializers.ModelSerializer): model = models.ProjectTemplate -class FanSerializer(serializers.ModelSerializer): - full_name = serializers.CharField(source='get_full_name', required=False) - - class Meta: - model = User - fields = ('id', 'username', 'first_name', 'last_name', 'full_name') - - class StarredSerializer(serializers.ModelSerializer): class Meta: model = models.Project diff --git a/taiga/projects/stars/__init__.py b/taiga/projects/stars/__init__.py deleted file mode 100644 index de86bcbc..00000000 --- a/taiga/projects/stars/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from .services import star -from .services import unstar -from .services import get_fans -from .services import get_starred -from .services import attach_startscount_to_queryset - - -__all__ = ("star", - "unstar", - "get_fans", - "get_starred", - "attach_startscount_to_queryset",) diff --git a/taiga/projects/stars/admin.py b/taiga/projects/stars/admin.py deleted file mode 100644 index 8c38f3f3..00000000 --- a/taiga/projects/stars/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/taiga/projects/stars/migrations/0001_initial.py b/taiga/projects/stars/migrations/0001_initial.py deleted file mode 100644 index 101886b9..00000000 --- a/taiga/projects/stars/migrations/0001_initial.py +++ /dev/null @@ -1,248 +0,0 @@ -# -*- coding: utf-8 -*- -from south.utils import datetime_utils as datetime -from south.db import db -from south.v2 import SchemaMigration -from django.db import models - - -class Migration(SchemaMigration): - - def forwards(self, orm): - # Adding model 'Fan' - db.create_table('stars_fan', ( - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('project', self.gf('django.db.models.fields.related.ForeignKey')(related_name='fans', to=orm['projects.Project'])), - ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='fans', to=orm['users.User'])), - )) - db.send_create_signal('stars', ['Fan']) - - # Adding unique constraint on 'Fan', fields ['project', 'user'] - db.create_unique('stars_fan', ['project_id', 'user_id']) - - # Adding model 'Stars' - db.create_table('stars_stars', ( - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('project', self.gf('django.db.models.fields.related.OneToOneField')(unique=True, to=orm['projects.Project'])), - ('count', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)), - )) - db.send_create_signal('stars', ['Stars']) - - - def backwards(self, orm): - # Removing unique constraint on 'Fan', fields ['project', 'user'] - db.delete_unique('stars_fan', ['project_id', 'user_id']) - - # Deleting model 'Fan' - db.delete_table('stars_fan') - - # Deleting model 'Stars' - db.delete_table('stars_stars') - - - models = { - 'auth.group': { - 'Meta': {'object_name': 'Group'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'to': "orm['auth.Permission']", 'symmetrical': 'False'}) - }, - 'auth.permission': { - 'Meta': {'unique_together': "(('content_type', 'codename'),)", 'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'object_name': 'Permission'}, - 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - 'contenttypes.contenttype': { - 'Meta': {'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'ordering': "('name',)", 'db_table': "'django_content_type'"}, - 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) - }, - 'domains.domain': { - 'Meta': {'ordering': "('domain',)", 'object_name': 'Domain'}, - 'alias_of': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'null': 'True', 'default': 'None', 'related_name': "'+'", 'to': "orm['domains.Domain']"}), - 'default_language': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': "''", 'max_length': '20'}), - 'domain': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'public_register': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'scheme': ('django.db.models.fields.CharField', [], {'null': 'True', 'default': 'None', 'max_length': '60'}) - }, - 'projects.issuestatus': { - 'Meta': {'unique_together': "(('project', 'name'),)", 'ordering': "['project', 'order', 'name']", 'object_name': 'IssueStatus'}, - 'color': ('django.db.models.fields.CharField', [], {'default': "'#999999'", 'max_length': '20'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}), - 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'issue_statuses'", 'to': "orm['projects.Project']"}) - }, - 'projects.issuetype': { - 'Meta': {'unique_together': "(('project', 'name'),)", 'ordering': "['project', 'order', 'name']", 'object_name': 'IssueType'}, - 'color': ('django.db.models.fields.CharField', [], {'default': "'#999999'", 'max_length': '20'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}), - 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'issue_types'", 'to': "orm['projects.Project']"}) - }, - 'projects.membership': { - 'Meta': {'unique_together': "(('user', 'project'),)", 'ordering': "['project', 'role']", 'object_name': 'Membership'}, - 'created_at': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'default': 'datetime.datetime.now', 'auto_now_add': 'True'}), - 'email': ('django.db.models.fields.EmailField', [], {'blank': 'True', 'null': 'True', 'default': 'None', 'max_length': '255'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'memberships'", 'to': "orm['projects.Project']"}), - 'role': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'memberships'", 'to': "orm['users.Role']"}), - 'token': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'default': 'None', 'max_length': '60'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'null': 'True', 'default': 'None', 'related_name': "'memberships'", 'to': "orm['users.User']"}) - }, - 'projects.points': { - 'Meta': {'unique_together': "(('project', 'name'),)", 'ordering': "['project', 'order', 'name']", 'object_name': 'Points'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}), - 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'points'", 'to': "orm['projects.Project']"}), - 'value': ('django.db.models.fields.FloatField', [], {'blank': 'True', 'null': 'True', 'default': 'None'}) - }, - 'projects.priority': { - 'Meta': {'unique_together': "(('project', 'name'),)", 'ordering': "['project', 'order', 'name']", 'object_name': 'Priority'}, - 'color': ('django.db.models.fields.CharField', [], {'default': "'#999999'", 'max_length': '20'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}), - 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'priorities'", 'to': "orm['projects.Project']"}) - }, - 'projects.project': { - 'Meta': {'ordering': "['name']", 'object_name': 'Project'}, - 'created_date': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now_add': 'True'}), - 'creation_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'null': 'True', 'default': 'None', 'related_name': "'projects'", 'to': "orm['projects.ProjectTemplate']"}), - 'default_issue_status': ('django.db.models.fields.related.OneToOneField', [], {'blank': 'True', 'on_delete': 'models.SET_NULL', 'unique': 'True', 'null': 'True', 'related_name': "'+'", 'to': "orm['projects.IssueStatus']"}), - 'default_issue_type': ('django.db.models.fields.related.OneToOneField', [], {'blank': 'True', 'on_delete': 'models.SET_NULL', 'unique': 'True', 'null': 'True', 'related_name': "'+'", 'to': "orm['projects.IssueType']"}), - 'default_points': ('django.db.models.fields.related.OneToOneField', [], {'blank': 'True', 'on_delete': 'models.SET_NULL', 'unique': 'True', 'null': 'True', 'related_name': "'+'", 'to': "orm['projects.Points']"}), - 'default_priority': ('django.db.models.fields.related.OneToOneField', [], {'blank': 'True', 'on_delete': 'models.SET_NULL', 'unique': 'True', 'null': 'True', 'related_name': "'+'", 'to': "orm['projects.Priority']"}), - 'default_severity': ('django.db.models.fields.related.OneToOneField', [], {'blank': 'True', 'on_delete': 'models.SET_NULL', 'unique': 'True', 'null': 'True', 'related_name': "'+'", 'to': "orm['projects.Severity']"}), - 'default_task_status': ('django.db.models.fields.related.OneToOneField', [], {'blank': 'True', 'on_delete': 'models.SET_NULL', 'unique': 'True', 'null': 'True', 'related_name': "'+'", 'to': "orm['projects.TaskStatus']"}), - 'default_us_status': ('django.db.models.fields.related.OneToOneField', [], {'blank': 'True', 'on_delete': 'models.SET_NULL', 'unique': 'True', 'null': 'True', 'related_name': "'+'", 'to': "orm['projects.UserStoryStatus']"}), - 'description': ('django.db.models.fields.TextField', [], {}), - 'domain': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'null': 'True', 'default': 'None', 'related_name': "'projects'", 'to': "orm['domains.Domain']"}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_backlog_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'is_issues_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'is_kanban_activated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'is_wiki_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'members': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'projects'", 'to': "orm['users.User']", 'through': "orm['projects.Membership']"}), - 'modified_date': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '250'}), - 'owner': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'owned_projects'", 'to': "orm['users.User']"}), - 'public': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'blank': 'True', 'max_length': '250'}), - 'tags': ('picklefield.fields.PickledObjectField', [], {'blank': 'True'}), - 'total_milestones': ('django.db.models.fields.IntegerField', [], {'blank': 'True', 'null': 'True', 'default': '0'}), - 'total_story_points': ('django.db.models.fields.FloatField', [], {'null': 'True', 'default': 'None'}), - 'videoconferences': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'max_length': '250'}), - 'videoconferences_salt': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'max_length': '250'}) - }, - 'projects.projecttemplate': { - 'Meta': {'unique_together': "(['slug', 'domain'],)", 'ordering': "['name']", 'object_name': 'ProjectTemplate'}, - 'created_date': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now_add': 'True'}), - 'default_options': ('django_pgjson.fields.JsonField', [], {}), - 'default_owner_role': ('django.db.models.fields.CharField', [], {'max_length': '50'}), - 'description': ('django.db.models.fields.TextField', [], {}), - 'domain': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'null': 'True', 'default': 'None', 'related_name': "'templates'", 'to': "orm['domains.Domain']"}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_backlog_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'is_issues_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'is_kanban_activated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'is_wiki_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'issue_statuses': ('django_pgjson.fields.JsonField', [], {}), - 'issue_types': ('django_pgjson.fields.JsonField', [], {}), - 'modified_date': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '250'}), - 'points': ('django_pgjson.fields.JsonField', [], {}), - 'priorities': ('django_pgjson.fields.JsonField', [], {}), - 'roles': ('django_pgjson.fields.JsonField', [], {}), - 'severities': ('django_pgjson.fields.JsonField', [], {}), - 'slug': ('django.db.models.fields.SlugField', [], {'blank': 'True', 'max_length': '250'}), - 'task_statuses': ('django_pgjson.fields.JsonField', [], {}), - 'us_statuses': ('django_pgjson.fields.JsonField', [], {}), - 'videoconferences': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'max_length': '250'}), - 'videoconferences_salt': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'max_length': '250'}) - }, - 'projects.severity': { - 'Meta': {'unique_together': "(('project', 'name'),)", 'ordering': "['project', 'order', 'name']", 'object_name': 'Severity'}, - 'color': ('django.db.models.fields.CharField', [], {'default': "'#999999'", 'max_length': '20'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}), - 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'severities'", 'to': "orm['projects.Project']"}) - }, - 'projects.taskstatus': { - 'Meta': {'unique_together': "(('project', 'name'),)", 'ordering': "['project', 'order', 'name']", 'object_name': 'TaskStatus'}, - 'color': ('django.db.models.fields.CharField', [], {'default': "'#999999'", 'max_length': '20'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}), - 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'task_statuses'", 'to': "orm['projects.Project']"}) - }, - 'projects.userstorystatus': { - 'Meta': {'unique_together': "(('project', 'name'),)", 'ordering': "['project', 'order', 'name']", 'object_name': 'UserStoryStatus'}, - 'color': ('django.db.models.fields.CharField', [], {'default': "'#999999'", 'max_length': '20'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}), - 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'us_statuses'", 'to': "orm['projects.Project']"}), - 'wip_limit': ('django.db.models.fields.IntegerField', [], {'blank': 'True', 'null': 'True', 'default': 'None'}) - }, - 'stars.fan': { - 'Meta': {'unique_together': "(('project', 'user'),)", 'object_name': 'Fan'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'fans'", 'to': "orm['projects.Project']"}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'fans'", 'to': "orm['users.User']"}) - }, - 'stars.stars': { - 'Meta': {'object_name': 'Stars'}, - 'count': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'project': ('django.db.models.fields.related.OneToOneField', [], {'unique': 'True', 'to': "orm['projects.Project']"}) - }, - 'users.role': { - 'Meta': {'unique_together': "(('slug', 'project'),)", 'ordering': "['order', 'slug']", 'object_name': 'Role'}, - 'computable': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '200'}), - 'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'roles'", 'to': "orm['auth.Permission']"}), - 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'roles'", 'to': "orm['projects.Project']"}), - 'slug': ('django.db.models.fields.SlugField', [], {'blank': 'True', 'max_length': '250'}) - }, - 'users.user': { - 'Meta': {'ordering': "['username']", 'object_name': 'User'}, - 'color': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': "'#55c1f6'", 'max_length': '9'}), - 'colorize_tags': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'default_language': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': "''", 'max_length': '20'}), - 'default_timezone': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': "''", 'max_length': '20'}), - 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - 'email': ('django.db.models.fields.EmailField', [], {'blank': 'True', 'max_length': '75'}), - 'first_name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'max_length': '30'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'user_set'", 'to': "orm['auth.Group']", 'symmetrical': 'False'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'last_name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'max_length': '30'}), - 'notify_changes_by_me': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'notify_level': ('django.db.models.fields.CharField', [], {'default': "'all_owned_projects'", 'max_length': '32'}), - 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'photo': ('django.db.models.fields.files.FileField', [], {'blank': 'True', 'null': 'True', 'max_length': '500'}), - 'token': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'default': 'None', 'max_length': '200'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'user_set'", 'to': "orm['auth.Permission']", 'symmetrical': 'False'}), - 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) - } - } - - complete_apps = ['stars'] \ No newline at end of file diff --git a/taiga/projects/stars/models.py b/taiga/projects/stars/models.py deleted file mode 100644 index 3ee44863..00000000 --- a/taiga/projects/stars/models.py +++ /dev/null @@ -1,36 +0,0 @@ -from django.conf import settings -from django.utils.translation import ugettext_lazy as _ -from django.db import models - - -class Fan(models.Model): - project = models.ForeignKey("projects.Project", null=False, blank=False, related_name="fans", - verbose_name=_("project")) - user = models.ForeignKey(settings.AUTH_USER_MODEL, null=False, blank=False, - related_name="fans", verbose_name=_("fans")) - - class Meta: - verbose_name = _("Star") - verbose_name_plural = _("Stars") - unique_together = ("project", "user") - - def __unicode__(self): - return self.user - - @classmethod - def create(cls, *args, **kwargs): - return cls.objects.create(*args, **kwargs) - - -class Stars(models.Model): - project = models.OneToOneField("projects.Project", null=False, blank=False, - related_name="stars", verbose_name=_("project")) - count = models.PositiveIntegerField(null=False, blank=False, default=0, - verbose_name=_("count")) - - class Meta: - verbose_name = _("Stars") - verbose_name_plural = _("Stars") - - def __unicode__(self): - return "{} stars".format(self.count) diff --git a/taiga/projects/stars/services.py b/taiga/projects/stars/services.py deleted file mode 100644 index 98e335cc..00000000 --- a/taiga/projects/stars/services.py +++ /dev/null @@ -1,100 +0,0 @@ -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 Fan, Stars - - -def star(project, user): - """Star a project for an user. - - If the user has already starred the project nothing happends so this function can be considered - idempotent. - - :param project: :class:`~taiga.projects.models.Project` instance. - :param user: :class:`~taiga.users.models.User` instance. - """ - - with atomic(): - fan, created = Fan.objects.get_or_create(project=project, - user=user) - if not created: - return - - stars, _ = Stars.objects.get_or_create(project=project) - stars.count = F('count') + 1 - stars.save() - - -def unstar(project, user): - """ - Unstar a project for an user. - - If the user has not starred the project nothing happens so this function can be considered - idempotent. - - :param project: :class:`~taiga.projects.models.Project` instance. - :param user: :class:`~taiga.users.models.User` instance. - """ - - with atomic(): - qs = Fan.objects.filter(project=project, user=user) - if not qs.exists(): - return - - qs.delete() - - stars, _ = Stars.objects.get_or_create(project=project) - stars.count = F('count') - 1 - stars.save() - - -def get_stars(project): - """ - Get the count of stars a project have. - """ - instance, _ = Stars.objects.get_or_create(project=project) - return instance.count - - -def get_fans(project_or_id): - """Get the fans a project have.""" - qs = get_user_model().objects.get_queryset() - if isinstance(project_or_id, int): - qs = qs.filter(fans__project_id=project_or_id) - else: - qs = qs.filter(fans__project=project_or_id) - - return qs - - -def get_starred(user_or_id): - """Get the projects an user has starred.""" - project_model = get_model("projects", "Project") - qs = project_model.objects.get_queryset() - - if isinstance(user_or_id, int): - qs = qs.filter(fans__user_id=user_or_id) - else: - qs = qs.filter(fans__user=user_or_id) - - return qs - - -def attach_startscount_to_queryset(queryset): - """ - Attach stars count to each object of projects queryset. - - Because of lazynes of starts objects creation, this makes - much simple and more efficient way to access to project - starts number. - - (The other way was be do it on serializer with some try/except - blocks and additional queryes) - """ - sql = ("SELECT coalesce(stars_stars.count, 0) FROM stars_stars " - "WHERE stars_stars.project_id = projects_project.id ") - qs = queryset.extra(select={"starts_count": sql}) - return qs - diff --git a/taiga/projects/votes/__init__.py b/taiga/projects/votes/__init__.py index e69de29b..cfa9356b 100644 --- a/taiga/projects/votes/__init__.py +++ b/taiga/projects/votes/__init__.py @@ -0,0 +1,7 @@ +from .services import add_vote +from .services import remove_vote +from .services import get_voters +from .services import get_votes +from .services import get_voted +from .utils import attach_votescount_to_queryset +from . import serializers diff --git a/taiga/projects/votes/migrations/0001_initial.py b/taiga/projects/votes/migrations/0001_initial.py new file mode 100644 index 00000000..0733535b --- /dev/null +++ b/taiga/projects/votes/migrations/0001_initial.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'Votes' + db.create_table('votes_votes', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('content_type', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['contenttypes.ContentType'])), + ('object_id', self.gf('django.db.models.fields.PositiveIntegerField')()), + ('count', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)), + )) + db.send_create_signal('votes', ['Votes']) + + # Adding unique constraint on 'Votes', fields ['content_type', 'object_id'] + db.create_unique('votes_votes', ['content_type_id', 'object_id']) + + # Adding model 'Vote' + db.create_table('votes_vote', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('content_type', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['contenttypes.ContentType'])), + ('object_id', self.gf('django.db.models.fields.PositiveIntegerField')()), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['users.User'], related_name='votes')), + )) + db.send_create_signal('votes', ['Vote']) + + # Adding unique constraint on 'Vote', fields ['content_type', 'object_id', 'user'] + db.create_unique('votes_vote', ['content_type_id', 'object_id', 'user_id']) + + + def backwards(self, orm): + # Removing unique constraint on 'Vote', fields ['content_type', 'object_id', 'user'] + db.delete_unique('votes_vote', ['content_type_id', 'object_id', 'user_id']) + + # Removing unique constraint on 'Votes', fields ['content_type', 'object_id'] + db.delete_unique('votes_votes', ['content_type_id', 'object_id']) + + # Deleting model 'Votes' + db.delete_table('votes_votes') + + # Deleting model 'Vote' + db.delete_table('votes_vote') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '80', 'unique': 'True'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['auth.Permission']", 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'object_name': 'Permission', 'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)"}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'object_name': 'ContentType', 'db_table': "'django_content_type'", 'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'users.user': { + 'Meta': {'object_name': 'User', 'ordering': "['username']"}, + 'color': ('django.db.models.fields.CharField', [], {'default': "'#4f52fe'", 'max_length': '9', 'blank': 'True'}), + 'colorize_tags': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'default_language': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '20', 'blank': 'True'}), + 'default_timezone': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '20', 'blank': 'True'}), + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['auth.Group']", 'related_name': "'user_set'", 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'notify_changes_by_me': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'notify_level': ('django.db.models.fields.CharField', [], {'default': "'all_owned_projects'", 'max_length': '32'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'photo': ('django.db.models.fields.files.FileField', [], {'max_length': '500', 'blank': 'True', 'null': 'True'}), + 'token': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '200', 'blank': 'True', 'null': 'True'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['auth.Permission']", 'related_name': "'user_set'", 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'max_length': '30', 'unique': 'True'}) + }, + 'votes.vote': { + 'Meta': {'object_name': 'Vote', 'unique_together': "(('content_type', 'object_id', 'user'),)"}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['users.User']", 'related_name': "'votes'"}) + }, + 'votes.votes': { + 'Meta': {'object_name': 'Votes', 'unique_together': "(('content_type', 'object_id'),)"}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'count': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + } + } + + complete_apps = ['votes'] \ No newline at end of file diff --git a/taiga/projects/stars/migrations/__init__.py b/taiga/projects/votes/migrations/__init__.py similarity index 100% rename from taiga/projects/stars/migrations/__init__.py rename to taiga/projects/votes/migrations/__init__.py diff --git a/taiga/projects/votes/serializers.py b/taiga/projects/votes/serializers.py new file mode 100644 index 00000000..f5239377 --- /dev/null +++ b/taiga/projects/votes/serializers.py @@ -0,0 +1,11 @@ +from django.contrib.auth import get_user_model + +from rest_framework import serializers + + +class VoterSerializer(serializers.ModelSerializer): + full_name = serializers.CharField(source='get_full_name', required=False) + + class Meta: + model = get_user_model() + fields = ('id', 'username', 'first_name', 'last_name', 'full_name') diff --git a/taiga/projects/votes/services.py b/taiga/projects/votes/services.py index 114a969c..aa5e9165 100644 --- a/taiga/projects/votes/services.py +++ b/taiga/projects/votes/services.py @@ -76,17 +76,23 @@ def get_votes(obj): return 0 -def get_voted(user, obj_class): +def get_voted(user_or_id, model): """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. + :param user_or_id: :class:`~taiga.users.models.User` instance or id. + :param model: Show only objects of this kind. Can be any Django model class. - :return: + :return: Queryset of objects representing the votes of the user. """ - obj_type = get_model("contenttypes", "ContentType").objects.get_for_model(obj_class) + obj_type = get_model("contenttypes", "ContentType").objects.get_for_model(model) conditions = ('votes_vote.content_type_id = %s', - '%s.id = votes_vote.object_id' % obj_class._meta.db_table, + '%s.id = votes_vote.object_id' % model._meta.db_table, 'votes_vote.user_id = %s') - return obj_class.objects.extra(where=conditions, tables=('votes_vote',), - params=(obj_type.id, user.id)) + + if isinstance(user_or_id, get_user_model()): + user_id = user_or_id.id + else: + user_id = user_or_id + + return model.objects.extra(where=conditions, tables=('votes_vote',), + params=(obj_type.id, user_id)) diff --git a/taiga/projects/votes/utils.py b/taiga/projects/votes/utils.py new file mode 100644 index 00000000..3864cce8 --- /dev/null +++ b/taiga/projects/votes/utils.py @@ -0,0 +1,24 @@ +from django.db.models.loading import get_model + + +def attach_votescount_to_queryset(queryset, as_field="votes_count"): + """Attach votes count to each object of the queryset. + + Because of laziness of vote objects creation, this makes much simpler and more efficient to + access to voted-object number of votes. + + (The other way was to do it in the serializer with some try/except blocks and additional + queries) + + :param queryset: A Django queryset object. + :param as_field: Attach the votes-count as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + type = get_model("contenttypes", "ContentType").objects.get_for_model(model) + sql = ("SELECT coalesce(votes_votes.count, 0) FROM votes_votes " + "WHERE votes_votes.content_type_id = {type_id} AND votes_votes.object_id = {tbl}.id") + sql = sql.format(type_id=type.id, tbl=model._meta.db_table) + qs = queryset.extra(select={as_field: sql}) + return qs diff --git a/taiga/routers.py b/taiga/routers.py index 5c8d882d..1cb0b2f6 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -107,11 +107,13 @@ from taiga.projects.milestones.api import MilestoneViewSet from taiga.projects.userstories.api import UserStoryViewSet from taiga.projects.tasks.api import TaskViewSet from taiga.projects.issues.api import IssueViewSet +from taiga.projects.issues.api import VotersViewSet from taiga.projects.wiki.api import WikiViewSet, WikiLinkViewSet router.register(r"milestones", MilestoneViewSet, base_name="milestones") router.register(r"userstories", UserStoryViewSet, base_name="userstories") router.register(r"tasks", TaskViewSet, base_name="tasks") router.register(r"issues", IssueViewSet, base_name="issues") +router.register(r"issues/(?P\d+)/voters", VotersViewSet, base_name="issue-voters") router.register(r"wiki", WikiViewSet, base_name="wiki") router.register(r"wiki-links", WikiLinkViewSet, base_name="wiki-links") diff --git a/tests/factories.py b/tests/factories.py index 8b73abf9..9d700d41 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -192,3 +192,25 @@ class VotesFactory(Factory): class ContentTypeFactory(Factory): FACTORY_FOR = get_model("contenttypes", "ContentType") + FACTORY_DJANGO_GET_OR_CREATE = ("app_label", "model") + + app_label = factory.LazyAttribute(lambda obj: ContentTypeFactory.FACTORY_FOR._meta.app_label) + model = factory.LazyAttribute(lambda obj: ContentTypeFactory.FACTORY_FOR._meta.model_name) + + +def create_issue(**kwargs): + "Create an issue and its dependencies in an appropriate way." + owner = kwargs.pop("owner") if "owner" in kwargs else UserFactory() + project = ProjectFactory.create(owner=owner) + defaults = { + "project": project, + "owner": owner, + "status": IssueStatusFactory.create(project=project), + "milestone": MilestoneFactory.create(project=project), + "priority": PriorityFactory.create(project=project), + "severity": SeverityFactory.create(project=project), + "type": IssueTypeFactory.create(project=project), + } + defaults.update(kwargs) + + return IssueFactory.create(**defaults) diff --git a/tests/integration/test_stars.py b/tests/integration/test_stars.py index eea0333c..8660e3ff 100644 --- a/tests/integration/test_stars.py +++ b/tests/integration/test_stars.py @@ -57,7 +57,7 @@ def test_project_member_unstar_project(client): def test_list_project_fans(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) - fan = f.FanFactory.create(project=project) + fan = f.VoteFactory.create(content_object=project) url = reverse("project-fans-list", args=(project.id,)) client.login(user) @@ -70,7 +70,7 @@ def test_list_project_fans(client): def test_get_project_fan(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) - fan = f.FanFactory.create(project=project) + fan = f.VoteFactory.create(content_object=project) url = reverse("project-fans-detail", args=(project.id, fan.user.id)) client.login(user) @@ -82,33 +82,36 @@ def test_get_project_fan(client): def test_list_user_starred_projects(client): user = f.UserFactory.create() - fan = f.FanFactory.create(user=user) + project = f.ProjectFactory() url = reverse("user-starred-list", args=(user.id,)) + f.VoteFactory.create(user=user, content_object=project) client.login(user) response = client.get(url) assert response.status_code == 200 - assert response.data[0]['id'] == fan.project.id + assert response.data[0]['id'] == project.id def test_get_user_starred_project(client): user = f.UserFactory.create() - fan = f.FanFactory.create(user=user) - url = reverse("user-starred-detail", args=(user.id, fan.project.id)) + project = f.ProjectFactory() + url = reverse("user-starred-detail", args=(user.id, project.id)) + f.VoteFactory.create(user=user, content_object=project) client.login(user) response = client.get(url) assert response.status_code == 200 - assert response.data['id'] == fan.project.id + assert response.data['id'] == project.id def test_get_project_stars(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) - f.StarsFactory.create(project=project, count=5) url = reverse("projects-detail", args=(project.id,)) + f.VotesFactory.create(content_object=project, count=5) + f.VotesFactory.create(count=3) client.login(user) response = client.get(url) diff --git a/tests/integration/test_vote_issues.py b/tests/integration/test_vote_issues.py new file mode 100644 index 00000000..20f73748 --- /dev/null +++ b/tests/integration/test_vote_issues.py @@ -0,0 +1,68 @@ +import pytest +from django.core.urlresolvers import reverse +from django.contrib.contenttypes.models import ContentType + +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_upvote_issue(client): + user = f.UserFactory.create() + issue = f.create_issue(owner=user) + url = reverse("issues-upvote", args=(issue.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_downvote_issue(client): + user = f.UserFactory.create() + issue = f.create_issue(owner=user) + url = reverse("issues-downvote", args=(issue.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_list_issue_voters(client): + user = f.UserFactory.create() + issue = f.create_issue(owner=user) + url = reverse("issue-voters-list", args=(issue.id,)) + f.VoteFactory.create(content_object=issue, user=user) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data[0]['id'] == user.id + + +def test_get_issue_voter(client): + user = f.UserFactory.create() + issue = f.create_issue(owner=user) + vote = f.VoteFactory.create(content_object=issue, user=user) + url = reverse("issue-voters-detail", args=(issue.id, vote.user.id)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['id'] == vote.user.id + + +def test_get_issue_votes(client): + user = f.UserFactory.create() + issue = f.create_issue(owner=user) + url = reverse("issues-detail", args=(issue.id,)) + f.VotesFactory.create(content_object=issue, count=5) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['votes'] == 5 diff --git a/tests/unit/test_stars.py b/tests/unit/test_stars.py deleted file mode 100644 index 0b5fe77d..00000000 --- a/tests/unit/test_stars.py +++ /dev/null @@ -1,91 +0,0 @@ -from unittest import mock - -from taiga.projects.stars import services as stars -from .. import factories as f - - -def setup_module(module): - module.patcher = mock.patch.object(stars, "atomic", mock.MagicMock()) - module.patcher.start() - -def teardown_module(module): - module.patcher.stop() - - -def test_user_star_project(): - "An user can star a project" - user = f.UserFactory.build() - project = f.ProjectFactory.build() - - with mock.patch("taiga.projects.stars.services.Fan") as Fan: - with mock.patch("taiga.projects.stars.services.Stars") as Stars: - stars_instance = mock.Mock() - - Fan.objects.get_or_create = mock.MagicMock(return_value=(mock.Mock(), True)) - Stars.objects.get_or_create = mock.MagicMock(return_value=(stars_instance, True)) - - stars.star(project, user=user) - - assert stars_instance.count.connector == '+' - assert stars_instance.count.children[1] == 1 - assert stars_instance.save.called - - -def test_idempotence_user_star_project(): - "An user can star a project many times but only one star is counted" - user = f.UserFactory.build() - project = f.ProjectFactory.build() - - with mock.patch("taiga.projects.stars.services.Fan") as Fan: - with mock.patch("taiga.projects.stars.services.Stars") as Stars: - stars_instance = mock.Mock() - - Fan.objects.get_or_create = mock.MagicMock(return_value=(mock.Mock(), False)) - Stars.objects.get_or_create = mock.MagicMock(return_value=(stars_instance, True)) - - stars.star(project, user=user) - - assert not Fan.objects.create.called - assert not stars_instance.save.called - - -def test_user_unstar_project(): - "An user can unstar a project" - fan = f.FanFactory.build() - - with mock.patch("taiga.projects.stars.services.Fan") as Fan: - with mock.patch("taiga.projects.stars.services.Stars") as Stars: - delete_mock = mock.Mock() - stars_instance = mock.Mock() - - Fan.objects.filter(project=fan.project, user=fan.user).exists = mock.Mock( - return_value=True) - Fan.objects.filter(project=fan.project, user=fan.user).delete = delete_mock - Stars.objects.get_or_create = mock.MagicMock(return_value=(stars_instance, True)) - - stars.unstar(fan.project, user=fan.user) - - assert delete_mock.called - assert stars_instance.count.connector == '-' - assert stars_instance.count.children[1] == 1 - assert stars_instance.save.called - - -def test_idempotence_user_unstar_project(): - "An user can unstar a project many times but only one star is discounted" - fan = f.FanFactory.build() - - with mock.patch("taiga.projects.stars.services.Fan") as Fan: - with mock.patch("taiga.projects.stars.services.Stars") as Stars: - delete_mock = mock.Mock() - stars_instance = mock.Mock() - - Fan.objects.filter(project=fan.project, user=fan.user).exists = mock.Mock( - return_value=False) - Fan.objects.filter(project=fan.project, user=fan.user).delete = delete_mock - Stars.objects.get_or_create = mock.MagicMock(return_value=(stars_instance, True)) - - stars.unstar(fan.project, user=fan.user) - - assert not delete_mock.called - assert not stars_instance.save.called From aa7ca6e3fcb97f58191eb5ef1f717f1b16125cb3 Mon Sep 17 00:00:00 2001 From: Anler Hp Date: Mon, 2 Jun 2014 16:57:48 +0200 Subject: [PATCH 3/3] Reconnect disconnected signals in integration tests --- tests/integration/test_neighbors.py | 6 +++++- tests/utils.py | 19 ++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/integration/test_neighbors.py b/tests/integration/test_neighbors.py index 00abab52..a55dd267 100644 --- a/tests/integration/test_neighbors.py +++ b/tests/integration/test_neighbors.py @@ -9,13 +9,17 @@ from taiga.base.utils.db import filter_by_tags from taiga.base import neighbors as n from .. import factories as f -from ..utils import disconnect_signals +from ..utils import disconnect_signals, reconnect_signals def setup_module(): disconnect_signals() +def teardown_module(): + reconnect_signals() + + class TestGetAttribute: def test_no_attribute(self, object): object.first_name = "name" diff --git a/tests/utils.py b/tests/utils.py index e70b3249..a96eb52e 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,6 +1,19 @@ from django.db.models import signals -def disconnect_signals(): - signals.pre_save.receivers = [] - signals.post_save.receivers = [] +def signals_switch(): + pre_save = signals.pre_save.receivers + post_save = signals.post_save.receivers + + def disconnect(): + signals.pre_save.receivers = [] + signals.post_save.receivers = [] + + def reconnect(): + signals.pre_save.receivers = pre_save + signals.post_save.receivers = post_save + + return disconnect, reconnect + + +disconnect_signals, reconnect_signals = signals_switch()