diff --git a/settings/common.py b/settings/common.py index 9568ad8c..355a6597 100644 --- a/settings/common.py +++ b/settings/common.py @@ -295,6 +295,7 @@ INSTALLED_APPS = [ "taiga.projects.history", "taiga.projects.notifications", "taiga.projects.attachments", + "taiga.projects.likes", "taiga.projects.votes", "taiga.projects.milestones", "taiga.projects.userstories", diff --git a/taiga/projects/api.py b/taiga/projects/api.py index efec71c1..4b3c47aa 100644 --- a/taiga/projects/api.py +++ b/taiga/projects/api.py @@ -44,7 +44,7 @@ from taiga.projects.mixins.on_destroy import MoveOnDestroyMixin from taiga.projects.userstories.models import UserStory, RolePoints from taiga.projects.tasks.models import Task from taiga.projects.issues.models import Issue -from taiga.projects.votes.mixins.viewsets import LikedResourceMixin, FansViewSetMixin +from taiga.projects.likes.mixins.viewsets import LikedResourceMixin, FansViewSetMixin from taiga.permissions import service as permissions_service from . import serializers @@ -68,7 +68,7 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet) def get_queryset(self): qs = super().get_queryset() - qs = self.attach_votes_attrs_to_queryset(qs) + qs = self.attach_likes_attrs_to_queryset(qs) qs = attach_project_total_watchers_attrs_to_queryset(qs) if self.request.user.is_authenticated(): qs = attach_project_is_watcher_to_queryset(qs, self.request.user) diff --git a/taiga/projects/likes/__init__.py b/taiga/projects/likes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/projects/likes/admin.py b/taiga/projects/likes/admin.py new file mode 100644 index 00000000..802eaca4 --- /dev/null +++ b/taiga/projects/likes/admin.py @@ -0,0 +1,25 @@ +# Copyright (C) 2014-2015 Andrey Antukh +# Copyright (C) 2014-2015 Jesús Espino +# Copyright (C) 2014-2015 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.contrib import admin +from django.contrib.contenttypes.admin import GenericTabularInline + +from . import models + + +class LikeInline(GenericTabularInline): + model = models.Like + extra = 0 diff --git a/taiga/projects/likes/migrations/0001_initial.py b/taiga/projects/likes/migrations/0001_initial.py new file mode 100644 index 00000000..e1a9dd6d --- /dev/null +++ b/taiga/projects/likes/migrations/0001_initial.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Like', + fields=[ + ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('created_date', models.DateTimeField(verbose_name='created date', auto_now_add=True)), + ('content_type', models.ForeignKey(to='contenttypes.ContentType')), + ('user', models.ForeignKey(related_name='likes', verbose_name='user', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Like', + 'verbose_name_plural': 'Likes', + }, + ), + migrations.CreateModel( + name='Likes', + fields=[ + ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('count', models.PositiveIntegerField(default=0, verbose_name='count')), + ('content_type', models.ForeignKey(to='contenttypes.ContentType')), + ], + options={ + 'verbose_name': 'Likes', + 'verbose_name_plural': 'Likes', + }, + ), + migrations.AlterUniqueTogether( + name='likes', + unique_together=set([('content_type', 'object_id')]), + ), + migrations.AlterUniqueTogether( + name='like', + unique_together=set([('content_type', 'object_id', 'user')]), + ), + ] diff --git a/taiga/projects/likes/migrations/__init__.py b/taiga/projects/likes/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/projects/likes/mixins/__init__.py b/taiga/projects/likes/mixins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/projects/likes/mixins/serializers.py b/taiga/projects/likes/mixins/serializers.py new file mode 100644 index 00000000..a4875b86 --- /dev/null +++ b/taiga/projects/likes/mixins/serializers.py @@ -0,0 +1,30 @@ +# Copyright (C) 2014-2015 Andrey Antukh +# Copyright (C) 2014-2015 Jesús Espino +# Copyright (C) 2014-2015 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from taiga.base.api import serializers + + +class FanResourceSerializerMixin(serializers.ModelSerializer): + is_fan = serializers.SerializerMethodField("get_is_fan") + total_fans = serializers.SerializerMethodField("get_total_fans") + + def get_is_fan(self, obj): + # The "is_fan" attribute is attached in the get_queryset of the viewset. + return getattr(obj, "is_fan", False) or False + + def get_total_fans(self, obj): + # The "total_fans" attribute is attached in the get_queryset of the viewset. + return getattr(obj, "total_fans", 0) or 0 diff --git a/taiga/projects/likes/mixins/viewsets.py b/taiga/projects/likes/mixins/viewsets.py new file mode 100644 index 00000000..b3d9b2e1 --- /dev/null +++ b/taiga/projects/likes/mixins/viewsets.py @@ -0,0 +1,92 @@ +# Copyright (C) 2014-2015 Andrey Antukh +# Copyright (C) 2014-2015 Jesús Espino +# Copyright (C) 2014-2015 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.core.exceptions import ObjectDoesNotExist + +from taiga.base import response +from taiga.base.api import viewsets +from taiga.base.api.utils import get_object_or_404 +from taiga.base.decorators import detail_route + +from taiga.projects.likes import serializers +from taiga.projects.likes import services +from taiga.projects.likes.utils import attach_total_fans_to_queryset, attach_is_fan_to_queryset + + +class LikedResourceMixin: + # Note: Update get_queryset method: + # def get_queryset(self): + # qs = super().get_queryset() + # return self.attach_likes_attrs_to_queryset(qs) + + def attach_likes_attrs_to_queryset(self, queryset): + qs = attach_total_fans_to_queryset(queryset) + + if self.request.user.is_authenticated(): + qs = attach_is_fan_to_queryset(self.request.user, qs) + + return qs + + @detail_route(methods=["POST"]) + def like(self, request, pk=None): + obj = self.get_object() + self.check_permissions(request, "like", obj) + + services.add_like(obj, user=request.user) + return response.Ok() + + @detail_route(methods=["POST"]) + def unlike(self, request, pk=None): + obj = self.get_object() + self.check_permissions(request, "unlike", obj) + + services.remove_like(obj, user=request.user) + return response.Ok() + + +class FansViewSetMixin: + # Is a ModelListViewSet with two required params: permission_classes and resource_model + serializer_class = serializers.FanSerializer + list_serializer_class = serializers.FanSerializer + permission_classes = None + resource_model = None + + def retrieve(self, request, *args, **kwargs): + pk = kwargs.get("pk", None) + resource_id = kwargs.get("resource_id", None) + resource = get_object_or_404(self.resource_model, pk=resource_id) + + self.check_permissions(request, 'retrieve', resource) + + try: + self.object = services.get_fans(resource).get(pk=pk) + except ObjectDoesNotExist: # or User.DoesNotExist + return response.NotFound() + + serializer = self.get_serializer(self.object) + return response.Ok(serializer.data) + + def list(self, request, *args, **kwargs): + resource_id = kwargs.get("resource_id", None) + resource = get_object_or_404(self.resource_model, pk=resource_id) + + self.check_permissions(request, 'list', resource) + + return super().list(request, *args, **kwargs) + + def get_queryset(self): + resource = self.resource_model.objects.get(pk=self.kwargs.get("resource_id")) + return services.get_fans(resource) diff --git a/taiga/projects/likes/models.py b/taiga/projects/likes/models.py new file mode 100644 index 00000000..9b56f923 --- /dev/null +++ b/taiga/projects/likes/models.py @@ -0,0 +1,66 @@ +# Copyright (C) 2014-2015 Andrey Antukh +# Copyright (C) 2014-2015 Jesús Espino +# Copyright (C) 2014-2015 David Barragán +# Copyright (C) 2014-2015 Anler Hernández +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.conf import settings +from django.contrib.contenttypes import generic +from django.db import models +from django.utils.translation import ugettext_lazy as _ + + +class Likes(models.Model): + content_type = models.ForeignKey("contenttypes.ContentType") + object_id = models.PositiveIntegerField() + content_object = generic.GenericForeignKey("content_type", "object_id") + count = models.PositiveIntegerField(null=False, blank=False, default=0, verbose_name=_("count")) + + class Meta: + verbose_name = _("Likes") + verbose_name_plural = _("Likes") + unique_together = ("content_type", "object_id") + + @property + def project(self): + if hasattr(self.content_object, 'project'): + return self.content_object.project + return None + + def __str__(self): + return self.count + + +class Like(models.Model): + content_type = models.ForeignKey("contenttypes.ContentType") + object_id = models.PositiveIntegerField() + content_object = generic.GenericForeignKey("content_type", "object_id") + user = models.ForeignKey(settings.AUTH_USER_MODEL, null=False, blank=False, + related_name="likes", verbose_name=_("user")) + created_date = models.DateTimeField(null=False, blank=False, auto_now_add=True, + verbose_name=_("created date")) + + class Meta: + verbose_name = _("Like") + verbose_name_plural = _("Likes") + unique_together = ("content_type", "object_id", "user") + + @property + def project(self): + if hasattr(self.content_object, 'project'): + return self.content_object.project + return None + + def __str__(self): + return self.user.get_full_name() diff --git a/taiga/projects/likes/serializers.py b/taiga/projects/likes/serializers.py new file mode 100644 index 00000000..c507166e --- /dev/null +++ b/taiga/projects/likes/serializers.py @@ -0,0 +1,30 @@ +# Copyright (C) 2014-2015 Andrey Antukh +# Copyright (C) 2014-2015 Jesús Espino +# Copyright (C) 2014-2015 David Barragán +# Copyright (C) 2014-2015 Anler Hernández +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from taiga.base.api import serializers +from taiga.base.fields import TagsField + +from taiga.users.models import User +from taiga.users.services import get_photo_or_gravatar_url + + +class FanSerializer(serializers.ModelSerializer): + full_name = serializers.CharField(source='get_full_name', required=False) + + class Meta: + model = User + fields = ('id', 'username', 'full_name') diff --git a/taiga/projects/likes/services.py b/taiga/projects/likes/services.py new file mode 100644 index 00000000..f9b94a7a --- /dev/null +++ b/taiga/projects/likes/services.py @@ -0,0 +1,114 @@ +# Copyright (C) 2014-2015 Andrey Antukh +# Copyright (C) 2014-2015 Jesús Espino +# Copyright (C) 2014-2015 David Barragán +# Copyright (C) 2014-2015 Anler Hernández +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.db.models import F +from django.db.transaction import atomic +from django.apps import apps +from django.contrib.auth import get_user_model + +from .models import Likes, Like + + +def add_like(obj, user): + """Add a like to an object. + + If the user has already liked the object nothing happends, so this function can be considered + idempotent. + + :param obj: Any Django model instance. + :param user: User adding the like. :class:`~taiga.users.models.User` instance. + """ + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj) + with atomic(): + like, created = Like.objects.get_or_create(content_type=obj_type, object_id=obj.id, user=user) + if not created: + return + + likes, _ = Likes.objects.get_or_create(content_type=obj_type, object_id=obj.id) + likes.count = F('count') + 1 + likes.save() + return like + + +def remove_like(obj, user): + """Remove an user like from an object. + + If the user has not liked the object nothing happens so this function can be considered + idempotent. + + :param obj: Any Django model instance. + :param user: User removing her like. :class:`~taiga.users.models.User` instance. + """ + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj) + with atomic(): + qs = Like.objects.filter(content_type=obj_type, object_id=obj.id, user=user) + if not qs.exists(): + return + + qs.delete() + + likes, _ = Likes.objects.get_or_create(content_type=obj_type, object_id=obj.id) + likes.count = F('count') - 1 + likes.save() + + +def get_fans(obj): + """Get the fans of an object. + + :param obj: Any Django model instance. + + :return: User queryset object representing the users that liked the object. + """ + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj) + return get_user_model().objects.filter(likes__content_type=obj_type, likes__object_id=obj.id) + + +def get_likes(obj): + """Get the number of likes an object has. + + :param obj: Any Django model instance. + + :return: Number of likes or `0` if the object has no likes at all. + """ + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj) + + try: + return Likes.objects.get(content_type=obj_type, object_id=obj.id).count + except Likes.DoesNotExist: + return 0 + + +def get_liked(user_or_id, model): + """Get the objects liked by an user. + + :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: Queryset of objects representing the likes of the user. + """ + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model) + conditions = ('likes_like.content_type_id = %s', + '%s.id = likes_like.object_id' % model._meta.db_table, + 'likes_like.user_id = %s') + + 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=('likes_like',), + params=(obj_type.id, user_id)) diff --git a/taiga/projects/likes/utils.py b/taiga/projects/likes/utils.py new file mode 100644 index 00000000..44035d47 --- /dev/null +++ b/taiga/projects/likes/utils.py @@ -0,0 +1,76 @@ +# Copyright (C) 2014-2015 Andrey Antukh +# Copyright (C) 2014-2015 Jesús Espino +# Copyright (C) 2014-2015 David Barragán +# Copyright (C) 2014-2015 Anler Hernández +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.apps import apps + + +def attach_total_fans_to_queryset(queryset, as_field="total_fans"): + """Attach likes count to each object of the queryset. + + Because of laziness of like objects creation, this makes much simpler and more efficient to + access to liked-object number of likes. + + (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 likes-count as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model) + sql = """SELECT coalesce(SUM(total_fans), 0) FROM ( + SELECT coalesce(likes_likes.count, 0) total_fans + FROM likes_likes + WHERE likes_likes.content_type_id = {type_id} + AND likes_likes.object_id = {tbl}.id + ) as e""" + + sql = sql.format(type_id=type.id, tbl=model._meta.db_table) + qs = queryset.extra(select={as_field: sql}) + return qs + + +def attach_is_fan_to_queryset(user, queryset, as_field="is_fan"): + """Attach is_like boolean to each object of the queryset. + + Because of laziness of like objects creation, this makes much simpler and more efficient to + access to likes-object and check if the curren user like it. + + (The other way was to do it in the serializer with some try/except blocks and additional + queries) + + :param user: A users.User object model + :param queryset: A Django queryset object. + :param as_field: Attach the boolean as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model) + sql = ("""SELECT CASE WHEN (SELECT count(*) + FROM likes_like + WHERE likes_like.content_type_id = {type_id} + AND likes_like.object_id = {tbl}.id + AND likes_like.user_id = {user_id}) > 0 + THEN TRUE + ELSE FALSE + END""") + sql = sql.format(type_id=type.id, tbl=model._meta.db_table, user_id=user.id) + qs = queryset.extra(select={as_field: sql}) + return qs diff --git a/taiga/projects/management/commands/sample_data.py b/taiga/projects/management/commands/sample_data.py index 55605c75..c9910962 100644 --- a/taiga/projects/management/commands/sample_data.py +++ b/taiga/projects/management/commands/sample_data.py @@ -37,6 +37,7 @@ from taiga.projects.attachments.models import * from taiga.projects.custom_attributes.models import * from taiga.projects.custom_attributes.choices import TYPES_CHOICES, TEXT_TYPE, MULTILINE_TYPE, DATE_TYPE from taiga.projects.history.services import take_snapshot +from taiga.projects.likes.services import add_like from taiga.projects.votes.services import add_vote from taiga.events.apps import disconnect_events_signals @@ -98,8 +99,9 @@ NUM_TASKS = getattr(settings, "SAMPLE_DATA_NUM_TASKS", (0, 4)) NUM_USS_BACK = getattr(settings, "SAMPLE_DATA_NUM_USS_BACK", (8, 20)) NUM_ISSUES = getattr(settings, "SAMPLE_DATA_NUM_ISSUES", (12, 25)) NUM_ATTACHMENTS = getattr(settings, "SAMPLE_DATA_NUM_ATTACHMENTS", (0, 4)) -NUM_VOTES = getattr(settings, "SAMPLE_DATA_NUM_VOTES", (0, 3)) -NUM_PROJECT_WATCHERS = getattr(settings, "SAMPLE_DATA_NUM_PROJECT_WATCHERS", (0, 3)) +NUM_LIKES = getattr(settings, "SAMPLE_DATA_NUM_LIKES", (0, 10)) +NUM_VOTES = getattr(settings, "SAMPLE_DATA_NUM_VOTES", (0, 10)) +NUM_WATCHERS = getattr(settings, "SAMPLE_DATA_NUM_PROJECT_WATCHERS", (0, 8)) class Command(BaseCommand): sd = SampleDataHelper(seed=12345678901) @@ -220,7 +222,7 @@ class Command(BaseCommand): project.total_story_points = int(defined_points * self.sd.int(5,12) / 10) project.save() - self.create_votes(project, project) + self.create_likes(project) def create_attachment(self, obj, order): attached_file = self.sd.file_from_directory(*ATTACHMENT_SAMPLE_DATA) @@ -301,9 +303,6 @@ class Command(BaseCommand): user__isnull=False)).user bug.save() - watching_user = self.sd.db_object_from_queryset(project.memberships.filter(user__isnull=False)).user - bug.add_watcher(watching_user) - take_snapshot(bug, comment=self.sd.paragraph(), user=bug.owner) @@ -315,7 +314,9 @@ class Command(BaseCommand): comment=self.sd.paragraph(), user=bug.owner) - self.create_votes(bug, project) + self.create_votes(bug) + self.create_watchers(bug) + return bug def create_task(self, project, milestone, us, min_date, max_date, closed=False): @@ -353,9 +354,6 @@ class Command(BaseCommand): comment=self.sd.paragraph(), user=task.owner) - watching_user = self.sd.db_object_from_queryset(project.memberships.filter(user__isnull=False)).user - task.add_watcher(watching_user) - # Add history entry task.status=self.sd.db_object_from_queryset(project.task_statuses.all()) task.save() @@ -363,7 +361,9 @@ class Command(BaseCommand): comment=self.sd.paragraph(), user=task.owner) - self.create_votes(task, project) + self.create_votes(task) + self.create_watchers(task) + return task def create_us(self, project, milestone=None, computable_project_roles=[]): @@ -404,8 +404,6 @@ class Command(BaseCommand): user__isnull=False)).user us.save() - watching_user = self.sd.db_object_from_queryset(project.memberships.filter(user__isnull=False)).user - us.add_watcher(watching_user) take_snapshot(us, comment=self.sd.paragraph(), @@ -418,7 +416,9 @@ class Command(BaseCommand): comment=self.sd.paragraph(), user=us.owner) - self.create_votes(us, project) + self.create_votes(us) + self.create_watchers(us) + return us def create_milestone(self, project, start_date, end_date): @@ -456,9 +456,8 @@ class Command(BaseCommand): project.save() take_snapshot(project, user=project.owner) - for i in range(self.sd.int(*NUM_PROJECT_WATCHERS)): - watching_user = self.sd.db_object_from_queryset(User.objects.all()) - project.add_watcher(watching_user) + self.create_likes(project) + self.create_watchers(project) return project @@ -479,7 +478,18 @@ class Command(BaseCommand): return user - def create_votes(self, obj, project): + def create_votes(self, obj): for i in range(self.sd.int(*NUM_VOTES)): - voting_user=self.sd.db_object_from_queryset(project.members.all()) - add_vote(obj, voting_user) + user=self.sd.db_object_from_queryset(User.objects.all()) + add_vote(obj, user) + + def create_likes(self, obj): + for i in range(self.sd.int(*NUM_LIKES)): + user=self.sd.db_object_from_queryset(User.objects.all()) + add_like(obj, user) + + def create_watchers(self, obj): + for i in range(self.sd.int(*NUM_WATCHERS)): + user = self.sd.db_object_from_queryset(User.objects.all()) + obj.add_watcher(user) + diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index 7a3380a8..21347615 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.py @@ -43,7 +43,7 @@ from .validators import ProjectExistsValidator from .custom_attributes.serializers import UserStoryCustomAttributeSerializer from .custom_attributes.serializers import TaskCustomAttributeSerializer from .custom_attributes.serializers import IssueCustomAttributeSerializer -from .votes.mixins.serializers import FanResourceSerializerMixin +from .likes.mixins.serializers import FanResourceSerializerMixin ###################################################### ## Custom values for selectors diff --git a/taiga/projects/services/stats.py b/taiga/projects/services/stats.py index 2a4753c4..998e5442 100644 --- a/taiga/projects/services/stats.py +++ b/taiga/projects/services/stats.py @@ -24,11 +24,11 @@ from taiga.projects.history.models import HistoryEntry def _get_total_story_points(project): - return (project.total_story_points if project.total_story_points is not None else + return (project.total_story_points if project.total_story_points not in [None, 0] else sum(project.calculated_points["defined"].values())) def _get_total_milestones(project): - return (project.total_milestones if project.total_milestones is not None else + return (project.total_milestones if project.total_milestones not in [None, 0] else project.milestones.count()) def _get_milestones_stats_for_backlog(project): diff --git a/taiga/projects/votes/mixins/serializers.py b/taiga/projects/votes/mixins/serializers.py index ed17d8a3..73e1799b 100644 --- a/taiga/projects/votes/mixins/serializers.py +++ b/taiga/projects/votes/mixins/serializers.py @@ -17,19 +17,6 @@ from taiga.base.api import serializers -class FanResourceSerializerMixin(serializers.ModelSerializer): - is_fan = serializers.SerializerMethodField("get_is_fan") - total_fans = serializers.SerializerMethodField("get_total_fans") - - def get_is_fan(self, obj): - # The "is_voted" attribute is attached in the get_queryset of the viewset. - return getattr(obj, "is_voter", False) or False - - def get_total_fans(self, obj): - # The "total_likes" attribute is attached in the get_queryset of the viewset. - return getattr(obj, "total_voters", 0) or 0 - - class VoteResourceSerializerMixin(serializers.ModelSerializer): is_voter = serializers.SerializerMethodField("get_is_voter") total_voters = serializers.SerializerMethodField("get_total_voters") @@ -39,5 +26,5 @@ class VoteResourceSerializerMixin(serializers.ModelSerializer): return getattr(obj, "is_voter", False) or False def get_total_voters(self, obj): - # The "total_likes" attribute is attached in the get_queryset of the viewset. + # The "total_voters" attribute is attached in the get_queryset of the viewset. return getattr(obj, "total_voters", 0) or 0 diff --git a/taiga/projects/votes/mixins/viewsets.py b/taiga/projects/votes/mixins/viewsets.py index 5148af7f..aa2100a0 100644 --- a/taiga/projects/votes/mixins/viewsets.py +++ b/taiga/projects/votes/mixins/viewsets.py @@ -26,7 +26,7 @@ from taiga.projects.votes import services from taiga.projects.votes.utils import attach_total_voters_to_queryset, attach_is_voter_to_queryset -class BaseVotedResource: +class VotedResourceMixin: # Note: Update get_queryset method: # def get_queryset(self): # qs = super().get_queryset() @@ -40,46 +40,24 @@ class BaseVotedResource: return qs - def _add_voter(self, permission, request, pk=None): + @detail_route(methods=["POST"]) + def upvote(self, request, pk=None): obj = self.get_object() - self.check_permissions(request, permission, obj) + self.check_permissions(request, "upvote", obj) services.add_vote(obj, user=request.user) return response.Ok() - def _remove_vote(self, permission, request, pk=None): + @detail_route(methods=["POST"]) + def downvote(self, request, pk=None): obj = self.get_object() - self.check_permissions(request, permission, obj) + self.check_permissions(request, "downvote", obj) services.remove_vote(obj, user=request.user) return response.Ok() -class LikedResourceMixin(BaseVotedResource): - # Note: objects nedd 'like' and 'unlike' permissions. - - @detail_route(methods=["POST"]) - def like(self, request, pk=None): - return self._add_voter("like", request, pk) - - @detail_route(methods=["POST"]) - def unlike(self, request, pk=None): - return self._remove_vote("unlike", request, pk) - - -class VotedResourceMixin(BaseVotedResource): - # Note: objects nedd 'upvote' and 'downvote' permissions. - - @detail_route(methods=["POST"]) - def upvote(self, request, pk=None): - return self._add_voter("upvote", request, pk) - - @detail_route(methods=["POST"]) - def downvote(self, request, pk=None): - return self._remove_vote("downvote", request, pk) - - -class BaseVotersViewSetMixin: +class VotersViewSetMixin: # Is a ModelListViewSet with two required params: permission_classes and resource_model serializer_class = serializers.VoterSerializer list_serializer_class = serializers.VoterSerializer @@ -112,11 +90,3 @@ class BaseVotersViewSetMixin: def get_queryset(self): resource = self.resource_model.objects.get(pk=self.kwargs.get("resource_id")) return services.get_voters(resource) - - -class VotersViewSetMixin(BaseVotersViewSetMixin): - pass - - -class FansViewSetMixin(BaseVotersViewSetMixin): - pass diff --git a/taiga/users/api.py b/taiga/users/api.py index 4c44422c..d72d021d 100644 --- a/taiga/users/api.py +++ b/taiga/users/api.py @@ -77,96 +77,64 @@ class UsersViewSet(ModelCrudViewSet): return response.Ok(serializer.data) - @list_route(methods=["GET"]) - def by_username(self, request, *args, **kwargs): - username = request.QUERY_PARAMS.get("username", None) - return self.retrieve(request, username=username) - def retrieve(self, request, *args, **kwargs): self.object = get_object_or_404(models.User, **kwargs) self.check_permissions(request, 'retrieve', self.object) serializer = self.get_serializer(self.object) return response.Ok(serializer.data) - @detail_route(methods=["GET"]) - def contacts(self, request, *args, **kwargs): - user = get_object_or_404(models.User, **kwargs) - self.check_permissions(request, 'contacts', user) + #TODO: commit_on_success + def partial_update(self, request, *args, **kwargs): + """ + We must detect if the user is trying to change his email so we can + save that value and generate a token that allows him to validate it in + the new email account + """ + user = self.get_object() + self.check_permissions(request, "update", user) - self.object_list = user_filters.ContactsFilterBackend().filter_queryset( - user, request, self.get_queryset(), self).extra( - select={"complete_user_name":"concat(full_name, username)"}).order_by("complete_user_name") + ret = super().partial_update(request, *args, **kwargs) - page = self.paginate_queryset(self.object_list) - if page is not None: - serializer = self.serializer_class(page.object_list, many=True) - else: - serializer = self.serializer_class(self.object_list, many=True) + new_email = request.DATA.get('email', None) + if new_email is not None: + valid_new_email = True + duplicated_email = models.User.objects.filter(email = new_email).exists() - return response.Ok(serializer.data) + try: + validate_email(new_email) + except ValidationError: + valid_new_email = False - @detail_route(methods=["GET"]) - def stats(self, request, *args, **kwargs): - user = get_object_or_404(models.User, **kwargs) - self.check_permissions(request, "stats", user) - return response.Ok(services.get_stats_for_user(user, request.user)) + valid_new_email = valid_new_email and new_email != request.user.email + if duplicated_email: + raise exc.WrongArguments(_("Duplicated email")) + elif not valid_new_email: + raise exc.WrongArguments(_("Not valid email")) - def _serialize_liked_content(self, elem, **kwargs): - if elem.get("type") == "project": - serializer = serializers.FanSerializer - else: - serializer = serializers.VotedSerializer + #We need to generate a token for the email + request.user.email_token = str(uuid.uuid1()) + request.user.new_email = new_email + request.user.save(update_fields=["email_token", "new_email"]) + email = mail_builder.change_email(request.user.new_email, {"user": request.user, + "lang": request.user.lang}) + email.send() - return serializer(elem, **kwargs) + return ret + def destroy(self, request, pk=None): + user = self.get_object() + self.check_permissions(request, "destroy", user) + stream = request.stream + request_data = stream is not None and stream.GET or None + user_cancel_account_signal.send(sender=user.__class__, user=user, request_data=request_data) + user.cancel() + return response.NoContent() - @detail_route(methods=["GET"]) - def watched(self, request, *args, **kwargs): - for_user = get_object_or_404(models.User, **kwargs) - from_user = request.user - self.check_permissions(request, 'watched', for_user) - filters = { - "type": request.GET.get("type", None), - "q": request.GET.get("q", None), - } - - self.object_list = services.get_watched_list(for_user, from_user, **filters) - page = self.paginate_queryset(self.object_list) - elements = page.object_list if page is not None else self.object_list - - extra_args = { - "user_votes": services.get_voted_content_for_user(request.user), - "user_watching": services.get_watched_content_for_user(request.user), - } - - response_data = [self._serialize_liked_content(elem, **extra_args).data for elem in elements] - return response.Ok(response_data) - - - @detail_route(methods=["GET"]) - def liked(self, request, *args, **kwargs): - for_user = get_object_or_404(models.User, **kwargs) - from_user = request.user - self.check_permissions(request, 'liked', for_user) - filters = { - "type": request.GET.get("type", None), - "q": request.GET.get("q", None), - } - - self.object_list = services.get_voted_list(for_user, from_user, **filters) - page = self.paginate_queryset(self.object_list) - elements = page.object_list if page is not None else self.object_list - - extra_args = { - "user_votes": services.get_voted_content_for_user(request.user), - "user_watching": services.get_watched_content_for_user(request.user), - } - - response_data = [self._serialize_liked_content(elem, **extra_args).data for elem in elements] - - return response.Ok(response_data) - + @list_route(methods=["GET"]) + def by_username(self, request, *args, **kwargs): + username = request.QUERY_PARAMS.get("username", None) + return self.retrieve(request, username=username) @list_route(methods=["POST"]) def password_recovery(self, request, pk=None): @@ -278,45 +246,6 @@ class UsersViewSet(ModelCrudViewSet): user_data = self.admin_serializer_class(request.user).data return response.Ok(user_data) - #TODO: commit_on_success - def partial_update(self, request, *args, **kwargs): - """ - We must detect if the user is trying to change his email so we can - save that value and generate a token that allows him to validate it in - the new email account - """ - user = self.get_object() - self.check_permissions(request, "update", user) - - ret = super(UsersViewSet, self).partial_update(request, *args, **kwargs) - - new_email = request.DATA.get('email', None) - if new_email is not None: - valid_new_email = True - duplicated_email = models.User.objects.filter(email = new_email).exists() - - try: - validate_email(new_email) - except ValidationError: - valid_new_email = False - - valid_new_email = valid_new_email and new_email != request.user.email - - if duplicated_email: - raise exc.WrongArguments(_("Duplicated email")) - elif not valid_new_email: - raise exc.WrongArguments(_("Not valid email")) - - #We need to generate a token for the email - request.user.email_token = str(uuid.uuid1()) - request.user.new_email = new_email - request.user.save(update_fields=["email_token", "new_email"]) - email = mail_builder.change_email(request.user.new_email, {"user": request.user, - "lang": request.user.lang}) - email.send() - - return ret - @list_route(methods=["POST"]) def change_email(self, request, pk=None): """ @@ -373,15 +302,108 @@ class UsersViewSet(ModelCrudViewSet): user.cancel() return response.NoContent() - def destroy(self, request, pk=None): - user = self.get_object() - self.check_permissions(request, "destroy", user) - stream = request.stream - request_data = stream is not None and stream.GET or None - user_cancel_account_signal.send(sender=user.__class__, user=user, request_data=request_data) - user.cancel() - return response.NoContent() + @detail_route(methods=["GET"]) + def contacts(self, request, *args, **kwargs): + user = get_object_or_404(models.User, **kwargs) + self.check_permissions(request, 'contacts', user) + self.object_list = user_filters.ContactsFilterBackend().filter_queryset( + user, request, self.get_queryset(), self).extra( + select={"complete_user_name":"concat(full_name, username)"}).order_by("complete_user_name") + + page = self.paginate_queryset(self.object_list) + if page is not None: + serializer = self.serializer_class(page.object_list, many=True) + else: + serializer = self.serializer_class(self.object_list, many=True) + + return response.Ok(serializer.data) + + @detail_route(methods=["GET"]) + def stats(self, request, *args, **kwargs): + user = get_object_or_404(models.User, **kwargs) + self.check_permissions(request, "stats", user) + return response.Ok(services.get_stats_for_user(user, request.user)) + + @detail_route(methods=["GET"]) + def watched(self, request, *args, **kwargs): + for_user = get_object_or_404(models.User, **kwargs) + from_user = request.user + self.check_permissions(request, 'watched', for_user) + filters = { + "type": request.GET.get("type", None), + "q": request.GET.get("q", None), + } + + self.object_list = services.get_watched_list(for_user, from_user, **filters) + page = self.paginate_queryset(self.object_list) + elements = page.object_list if page is not None else self.object_list + + extra_args_liked = { + "user_watching": services.get_watched_content_for_user(request.user), + "user_likes": services.get_liked_content_for_user(request.user), + } + + extra_args_voted = { + "user_watching": services.get_watched_content_for_user(request.user), + "user_votes": services.get_voted_content_for_user(request.user), + } + + response_data = [] + for elem in elements: + if elem["type"] == "project": + # projects are liked objects + response_data.append(serializers.LikedObjectSerializer(elem, **extra_args_liked).data ) + else: + # stories, tasks and issues are voted objects + response_data.append(serializers.VotedObjectSerializer(elem, **extra_args_voted).data ) + + return response.Ok(response_data) + + @detail_route(methods=["GET"]) + def liked(self, request, *args, **kwargs): + for_user = get_object_or_404(models.User, **kwargs) + from_user = request.user + self.check_permissions(request, 'liked', for_user) + filters = { + "q": request.GET.get("q", None), + } + + self.object_list = services.get_liked_list(for_user, from_user, **filters) + page = self.paginate_queryset(self.object_list) + elements = page.object_list if page is not None else self.object_list + + extra_args = { + "user_watching": services.get_watched_content_for_user(request.user), + "user_likes": services.get_liked_content_for_user(request.user), + } + + response_data = [serializers.LikedObjectSerializer(elem, **extra_args).data for elem in elements] + + return response.Ok(response_data) + + @detail_route(methods=["GET"]) + def voted(self, request, *args, **kwargs): + for_user = get_object_or_404(models.User, **kwargs) + from_user = request.user + self.check_permissions(request, 'liked', for_user) + filters = { + "type": request.GET.get("type", None), + "q": request.GET.get("q", None), + } + + self.object_list = services.get_voted_list(for_user, from_user, **filters) + page = self.paginate_queryset(self.object_list) + elements = page.object_list if page is not None else self.object_list + + extra_args = { + "user_watching": services.get_watched_content_for_user(request.user), + "user_votes": services.get_voted_content_for_user(request.user), + } + + response_data = [serializers.VotedObjectSerializer(elem, **extra_args).data for elem in elements] + + return response.Ok(response_data) ###################################################### ## Role diff --git a/taiga/users/permissions.py b/taiga/users/permissions.py index 5426d3f5..de72dd85 100644 --- a/taiga/users/permissions.py +++ b/taiga/users/permissions.py @@ -47,6 +47,7 @@ class UserPermission(TaigaResourcePermission): change_email_perms = AllowAny() contacts_perms = AllowAny() liked_perms = AllowAny() + voted_perms = AllowAny() watched_perms = AllowAny() diff --git a/taiga/users/serializers.py b/taiga/users/serializers.py index 24e5eb82..2a7000fd 100644 --- a/taiga/users/serializers.py +++ b/taiga/users/serializers.py @@ -159,7 +159,7 @@ class ProjectRoleSerializer(serializers.ModelSerializer): ###################################################### -class LikeSerializer(serializers.Serializer): +class HighLightedContentSerializer(serializers.Serializer): type = serializers.CharField() id = serializers.IntegerField() ref = serializers.IntegerField() @@ -174,9 +174,6 @@ class LikeSerializer(serializers.Serializer): created_date = serializers.DateTimeField() is_private = serializers.SerializerMethodField("get_is_private") - is_watcher = serializers.SerializerMethodField("get_is_watcher") - total_watchers = serializers.IntegerField() - project = serializers.SerializerMethodField("get_project") project_name = serializers.SerializerMethodField("get_project_name") project_slug = serializers.SerializerMethodField("get_project_slug") @@ -186,13 +183,15 @@ class LikeSerializer(serializers.Serializer): assigned_to_full_name = serializers.CharField() assigned_to_photo = serializers.SerializerMethodField("get_photo") + is_watcher = serializers.SerializerMethodField("get_is_watcher") + total_watchers = serializers.IntegerField() + def __init__(self, *args, **kwargs): # Don't pass the extra ids args up to the superclass - self.user_votes = kwargs.pop("user_votes", {}) self.user_watching = kwargs.pop("user_watching", {}) # Instantiate the superclass normally - super(LikeSerializer, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def _none_if_project(self, obj, property): type = obj.get("type", "") @@ -226,9 +225,6 @@ class LikeSerializer(serializers.Serializer): def get_project_is_private(self, obj): return self._none_if_project(obj, "project_is_private") - def get_is_watcher(self, obj): - return obj["id"] in self.user_watching.get(obj["type"], []) - def get_photo(self, obj): type = obj.get("type", "") if type == "project": @@ -242,18 +238,35 @@ class LikeSerializer(serializers.Serializer): tags = obj.get("tags", []) return [{"name": tc[0], "color": tc[1]} for tc in obj.get("tags_colors", []) if tc[0] in tags] + def get_is_watcher(self, obj): + return obj["id"] in self.user_watching.get(obj["type"], []) -class FanSerializer(LikeSerializer): +class LikedObjectSerializer(HighLightedContentSerializer): is_fan = serializers.SerializerMethodField("get_is_fan") - total_fans = serializers.IntegerField(source="total_voters") + total_fans = serializers.IntegerField() + + def __init__(self, *args, **kwargs): + # Don't pass the extra ids args up to the superclass + self.user_likes = kwargs.pop("user_likes", {}) + + # Instantiate the superclass normally + super().__init__(*args, **kwargs) def get_is_fan(self, obj): - return obj["id"] in self.user_votes.get(obj["type"], []) + return obj["id"] in self.user_likes.get(obj["type"], []) -class VotedSerializer(LikeSerializer): + +class VotedObjectSerializer(HighLightedContentSerializer): is_voter = serializers.SerializerMethodField("get_is_voter") total_voters = serializers.IntegerField() + def __init__(self, *args, **kwargs): + # Don't pass the extra ids args up to the superclass + self.user_votes = kwargs.pop("user_votes", {}) + + # Instantiate the superclass normally + super().__init__(*args, **kwargs) + def get_is_voter(self, obj): return obj["id"] in self.user_votes.get(obj["type"], []) diff --git a/taiga/users/services.py b/taiga/users/services.py index 534b9b6e..4542d22b 100644 --- a/taiga/users/services.py +++ b/taiga/users/services.py @@ -149,6 +149,23 @@ def get_stats_for_user(from_user, by_user): return project_stats +def get_liked_content_for_user(user): + """Returns a dict where: + - The key is the content_type model + - The values are list of id's of the different objects liked by the user + """ + if user.is_anonymous(): + return {} + + user_likes = {} + for (ct_model, object_id) in user.likes.values_list("content_type__model", "object_id"): + list = user_likes.get(ct_model, []) + list.append(object_id) + user_likes[ct_model] = list + + return user_likes + + def get_voted_content_for_user(user): """Returns a dict where: - The key is the content_type model @@ -190,11 +207,12 @@ def get_watched_content_for_user(user): def _build_watched_sql_for_projects(for_user): sql = """ - SELECT projects_project.id AS id, null AS ref, 'project' AS type, + SELECT projects_project.id AS id, null::integer AS ref, 'project'::text AS type, tags, notifications_notifypolicy.project_id AS object_id, projects_project.id AS project, - slug AS slug, projects_project.name AS name, null AS subject, - notifications_notifypolicy.created_at as created_date, coalesce(watchers, 0) as total_watchers, coalesce(votes_votes.count, 0) total_voters, null AS assigned_to, - null as status, null as status_color + slug, projects_project.name, null::text AS subject, + notifications_notifypolicy.created_at as created_date, + coalesce(watchers, 0) AS total_watchers, coalesce(likes_likes.count, 0) AS total_fans, null::integer AS total_voters, + null::integer AS assigned_to, null::text as status, null::text as status_color FROM notifications_notifypolicy INNER JOIN projects_project ON (projects_project.id = notifications_notifypolicy.project_id) @@ -204,8 +222,8 @@ def _build_watched_sql_for_projects(for_user): GROUP BY project_id ) type_watchers ON projects_project.id = type_watchers.project_id - LEFT JOIN votes_votes - ON (projects_project.id = votes_votes.object_id AND {project_content_type_id} = votes_votes.content_type_id) + LEFT JOIN likes_likes + ON (projects_project.id = likes_likes.object_id AND {project_content_type_id} = likes_likes.content_type_id) WHERE notifications_notifypolicy.user_id = {for_user_id} """ sql = sql.format( @@ -215,30 +233,32 @@ def _build_watched_sql_for_projects(for_user): return sql -def _build_voted_sql_for_projects(for_user): +def _build_liked_sql_for_projects(for_user): sql = """ - SELECT projects_project.id AS id, null AS ref, 'project' AS type, - tags, votes_vote.object_id AS object_id, projects_project.id AS project, - slug AS slug, projects_project.name AS name, null AS subject, - votes_vote.created_date, coalesce(watchers, 0) as total_watchers, coalesce(votes_votes.count, 0) total_voters, null AS assigned_to, - null as status, null as status_color - FROM votes_vote + SELECT projects_project.id AS id, null::integer AS ref, 'project'::text AS type, + tags, likes_like.object_id AS object_id, projects_project.id AS project, + slug, projects_project.name, null::text AS subject, + likes_like.created_date, + coalesce(watchers, 0) AS total_watchers, coalesce(likes_likes.count, 0) AS total_fans, + null::integer AS assigned_to, null::text as status, null::text as status_color + FROM likes_like INNER JOIN projects_project - ON (projects_project.id = votes_vote.object_id) + ON (projects_project.id = likes_like.object_id) LEFT JOIN (SELECT project_id, count(*) watchers FROM notifications_notifypolicy WHERE notifications_notifypolicy.notify_level != {ignore_notify_level} GROUP BY project_id ) type_watchers ON projects_project.id = type_watchers.project_id - LEFT JOIN votes_votes - ON (projects_project.id = votes_votes.object_id AND {project_content_type_id} = votes_votes.content_type_id) - WHERE votes_vote.user_id = {for_user_id} AND {project_content_type_id} = votes_vote.content_type_id + LEFT JOIN likes_likes + ON (projects_project.id = likes_likes.object_id AND {project_content_type_id} = likes_likes.content_type_id) + WHERE likes_like.user_id = {for_user_id} AND {project_content_type_id} = likes_like.content_type_id """ sql = sql.format( for_user_id=for_user.id, ignore_notify_level=NotifyLevel.ignore, project_content_type_id=ContentType.objects.get(app_label="projects", model="project").id) + return sql @@ -249,8 +269,9 @@ def _build_sql_for_type(for_user, type, table_name, action_table, ref_column="re SELECT {table_name}.id AS id, {ref_column} AS ref, '{type}' AS type, tags, {action_table}.object_id AS object_id, {table_name}.{project_column} AS project, {slug_column} AS slug, null AS name, {subject_column} AS subject, - {action_table}.created_date, coalesce(watchers, 0) as total_watchers, coalesce(votes_votes.count, 0) total_voters, {assigned_to_column} AS assigned_to, - projects_{type}status.name as status, projects_{type}status.color as status_color + {action_table}.created_date, + coalesce(watchers, 0) AS total_watchers, null::integer AS total_fans, coalesce(votes_votes.count, 0) AS total_voters, + {assigned_to_column} AS assigned_to, projects_{type}status.name as status, projects_{type}status.color as status_color FROM {action_table} INNER JOIN django_content_type ON ({action_table}.content_type_id = django_content_type.id AND django_content_type.model = '{type}') @@ -272,7 +293,7 @@ def _build_sql_for_type(for_user, type, table_name, action_table, ref_column="re return sql -def _get_favourites_list(for_user, from_user, action_table, project_sql_builder, type=None, q=None): +def get_watched_list(for_user, from_user, type=None, q=None): filters_sql = "" and_needed = False @@ -348,10 +369,10 @@ def _get_favourites_list(for_user, from_user, action_table, project_sql_builder, for_user_id=for_user.id, from_user_id=from_user_id, filters_sql=filters_sql, - userstories_sql=_build_sql_for_type(for_user, "userstory", "userstories_userstory", action_table, slug_column="null"), - tasks_sql=_build_sql_for_type(for_user, "task", "tasks_task", action_table, slug_column="null"), - issues_sql=_build_sql_for_type(for_user, "issue", "issues_issue", action_table, slug_column="null"), - projects_sql=project_sql_builder(for_user)) + userstories_sql=_build_sql_for_type(for_user, "userstory", "userstories_userstory", "notifications_watched", slug_column="null"), + tasks_sql=_build_sql_for_type(for_user, "task", "tasks_task", "notifications_watched", slug_column="null"), + issues_sql=_build_sql_for_type(for_user, "issue", "issues_issue", "notifications_watched", slug_column="null"), + projects_sql=_build_watched_sql_for_projects(for_user)) cursor = connection.cursor() cursor.execute(sql) @@ -363,9 +384,167 @@ def _get_favourites_list(for_user, from_user, action_table, project_sql_builder, ] -def get_watched_list(for_user, from_user, type=None, q=None): - return _get_favourites_list(for_user, from_user, "notifications_watched", _build_watched_sql_for_projects, type=type, q=q) +def get_liked_list(for_user, from_user, type=None, q=None): + filters_sql = "" + and_needed = False + + if type: + filters_sql += " AND type = '{type}' ".format(type=type) + + if q: + filters_sql += """ AND ( + to_tsvector('english_nostop', coalesce(subject,'') || ' ' ||coalesce(entities.name,'') || ' ' ||coalesce(to_char(ref, '999'),'')) @@ to_tsquery('english_nostop', '{q}') + ) + """.format(q=to_tsquery(q)) + + sql = """ + -- BEGIN Basic info: we need to mix info from different tables and denormalize it + SELECT entities.*, + projects_project.name as project_name, projects_project.description as description, projects_project.slug as project_slug, projects_project.is_private as project_is_private, + projects_project.tags_colors, + users_user.username assigned_to_username, users_user.full_name assigned_to_full_name, users_user.photo assigned_to_photo, users_user.email assigned_to_email + FROM ( + {projects_sql} + ) as entities + -- END Basic info + + -- BEGIN Project info + LEFT JOIN projects_project + ON (entities.project = projects_project.id) + -- END Project info + + -- BEGIN Assigned to user info + LEFT JOIN users_user + ON (assigned_to = users_user.id) + -- END Assigned to user info + + -- BEGIN Permissions checking + LEFT JOIN projects_membership + -- Here we check the memberbships from the user requesting the info + ON (projects_membership.user_id = {from_user_id} AND projects_membership.project_id = entities.project) + + LEFT JOIN users_role + ON (entities.project = users_role.project_id AND users_role.id = projects_membership.role_id) + + WHERE + -- public project + ( + projects_project.is_private = false + OR( + -- private project where the view_ permission is included in the user role for that project or in the anon permissions + projects_project.is_private = true + AND( + 'view_project' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions)) + ) + )) + -- END Permissions checking + {filters_sql} + + ORDER BY entities.created_date DESC; + """ + + from_user_id = -1 + if not from_user.is_anonymous(): + from_user_id = from_user.id + + sql = sql.format( + for_user_id=for_user.id, + from_user_id=from_user_id, + filters_sql=filters_sql, + projects_sql=_build_liked_sql_for_projects(for_user)) + + cursor = connection.cursor() + cursor.execute(sql) + + desc = cursor.description + return [ + dict(zip([col[0] for col in desc], row)) + for row in cursor.fetchall() + ] def get_voted_list(for_user, from_user, type=None, q=None): - return _get_favourites_list(for_user, from_user, "votes_vote", _build_voted_sql_for_projects, type=type, q=q) + filters_sql = "" + and_needed = False + + if type: + filters_sql += " AND type = '{type}' ".format(type=type) + + if q: + filters_sql += """ AND ( + to_tsvector('english_nostop', coalesce(subject,'') || ' ' ||coalesce(entities.name,'') || ' ' ||coalesce(to_char(ref, '999'),'')) @@ to_tsquery('english_nostop', '{q}') + ) + """.format(q=to_tsquery(q)) + + sql = """ + -- BEGIN Basic info: we need to mix info from different tables and denormalize it + SELECT entities.*, + projects_project.name as project_name, projects_project.description as description, projects_project.slug as project_slug, projects_project.is_private as project_is_private, + projects_project.tags_colors, + users_user.username assigned_to_username, users_user.full_name assigned_to_full_name, users_user.photo assigned_to_photo, users_user.email assigned_to_email + FROM ( + {userstories_sql} + UNION + {tasks_sql} + UNION + {issues_sql} + ) as entities + -- END Basic info + + -- BEGIN Project info + LEFT JOIN projects_project + ON (entities.project = projects_project.id) + -- END Project info + + -- BEGIN Assigned to user info + LEFT JOIN users_user + ON (assigned_to = users_user.id) + -- END Assigned to user info + + -- BEGIN Permissions checking + LEFT JOIN projects_membership + -- Here we check the memberbships from the user requesting the info + ON (projects_membership.user_id = {from_user_id} AND projects_membership.project_id = entities.project) + + LEFT JOIN users_role + ON (entities.project = users_role.project_id AND users_role.id = projects_membership.role_id) + + WHERE + -- public project + ( + projects_project.is_private = false + OR( + -- private project where the view_ permission is included in the user role for that project or in the anon permissions + projects_project.is_private = true + AND( + (entities.type = 'issue' AND 'view_issues' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions))) + OR (entities.type = 'task' AND 'view_tasks' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions))) + OR (entities.type = 'userstory' AND 'view_us' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions))) + ) + )) + -- END Permissions checking + {filters_sql} + + ORDER BY entities.created_date DESC; + """ + + from_user_id = -1 + if not from_user.is_anonymous(): + from_user_id = from_user.id + + sql = sql.format( + for_user_id=for_user.id, + from_user_id=from_user_id, + filters_sql=filters_sql, + userstories_sql=_build_sql_for_type(for_user, "userstory", "userstories_userstory", "votes_vote", slug_column="null"), + tasks_sql=_build_sql_for_type(for_user, "task", "tasks_task", "votes_vote", slug_column="null"), + issues_sql=_build_sql_for_type(for_user, "issue", "issues_issue", "votes_vote", slug_column="null")) + + cursor = connection.cursor() + cursor.execute(sql) + + desc = cursor.description + return [ + dict(zip([col[0] for col in desc], row)) + for row in cursor.fetchall() + ] diff --git a/tests/factories.py b/tests/factories.py index 532faefe..c44167c5 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -412,14 +412,23 @@ class IssueCustomAttributesValuesFactory(Factory): issue = factory.SubFactory("tests.factories.IssueFactory") -# class FanFactory(Factory): -# project = factory.SubFactory("tests.factories.ProjectFactory") -# user = factory.SubFactory("tests.factories.UserFactory") +class LikeFactory(Factory): + class Meta: + model = "likes.Like" + strategy = factory.CREATE_STRATEGY + + content_type = factory.SubFactory("tests.factories.ContentTypeFactory") + object_id = factory.Sequence(lambda n: n) + user = factory.SubFactory("tests.factories.UserFactory") -# class StarsFactory(Factory): -# project = factory.SubFactory("tests.factories.ProjectFactory") -# count = 0 +class LikesFactory(Factory): + class Meta: + model = "likes.Likes" + strategy = factory.CREATE_STRATEGY + + content_type = factory.SubFactory("tests.factories.ContentTypeFactory") + object_id = factory.Sequence(lambda n: n) class VoteFactory(Factory): diff --git a/tests/integration/resources_permissions/test_projects_resource.py b/tests/integration/resources_permissions/test_projects_resource.py index 26132062..e604ac7f 100644 --- a/tests/integration/resources_permissions/test_projects_resource.py +++ b/tests/integration/resources_permissions/test_projects_resource.py @@ -70,16 +70,16 @@ def data(): project_ct = ContentType.objects.get_for_model(Project) - f.VoteFactory(content_type=project_ct, object_id=m.public_project.pk, user=m.project_member_with_perms) - f.VoteFactory(content_type=project_ct, object_id=m.public_project.pk, user=m.project_owner) - f.VoteFactory(content_type=project_ct, object_id=m.private_project1.pk, user=m.project_member_with_perms) - f.VoteFactory(content_type=project_ct, object_id=m.private_project1.pk, user=m.project_owner) - f.VoteFactory(content_type=project_ct, object_id=m.private_project2.pk, user=m.project_member_with_perms) - f.VoteFactory(content_type=project_ct, object_id=m.private_project2.pk, user=m.project_owner) + f.LikeFactory(content_type=project_ct, object_id=m.public_project.pk, user=m.project_member_with_perms) + f.LikeFactory(content_type=project_ct, object_id=m.public_project.pk, user=m.project_owner) + f.LikeFactory(content_type=project_ct, object_id=m.private_project1.pk, user=m.project_member_with_perms) + f.LikeFactory(content_type=project_ct, object_id=m.private_project1.pk, user=m.project_owner) + f.LikeFactory(content_type=project_ct, object_id=m.private_project2.pk, user=m.project_member_with_perms) + f.LikeFactory(content_type=project_ct, object_id=m.private_project2.pk, user=m.project_owner) - f.VotesFactory(content_type=project_ct, object_id=m.public_project.pk, count=2) - f.VotesFactory(content_type=project_ct, object_id=m.private_project1.pk, count=2) - f.VotesFactory(content_type=project_ct, object_id=m.private_project2.pk, count=2) + f.LikesFactory(content_type=project_ct, object_id=m.public_project.pk, count=2) + f.LikesFactory(content_type=project_ct, object_id=m.private_project1.pk, count=2) + f.LikesFactory(content_type=project_ct, object_id=m.private_project2.pk, count=2) return m diff --git a/tests/integration/resources_permissions/test_users_resources.py b/tests/integration/resources_permissions/test_users_resources.py index a78d270d..a6604988 100644 --- a/tests/integration/resources_permissions/test_users_resources.py +++ b/tests/integration/resources_permissions/test_users_resources.py @@ -311,3 +311,15 @@ def test_user_list_liked(client, data): ] results = helper_test_http_method(client, 'get', url, None, users) assert results == [200, 200, 200, 200] + + +def test_user_list_voted(client, data): + url = reverse('users-voted', kwargs={"pk": data.registered_user.pk}) + users = [ + None, + data.registered_user, + data.other_user, + data.superuser, + ] + results = helper_test_http_method(client, 'get', url, None, users) + assert results == [200, 200, 200, 200] diff --git a/tests/integration/test_fan_projects.py b/tests/integration/test_fan_projects.py new file mode 100644 index 00000000..6f60e50b --- /dev/null +++ b/tests/integration/test_fan_projects.py @@ -0,0 +1,123 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 David Barragán +# Copyright (C) 2015 Anler Hernández +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pytest +from django.core.urlresolvers import reverse + +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_like_project(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_owner=True) + url = reverse("projects-like", args=(project.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_unlike_project(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_owner=True) + url = reverse("projects-unlike", args=(project.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_list_project_fans(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_owner=True) + f.LikeFactory.create(content_object=project, user=user) + url = reverse("project-fans-list", args=(project.id,)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data[0]['id'] == user.id + + +def test_get_project_fan(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_owner=True) + like = f.LikeFactory.create(content_object=project, user=user) + url = reverse("project-fans-detail", args=(project.id, like.user.id)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['id'] == like.user.id + + +def test_get_project_total_fans(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_owner=True) + url = reverse("projects-detail", args=(project.id,)) + + f.LikesFactory.create(content_object=project, count=5) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['total_fans'] == 5 + + +def test_get_project_is_fan(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_owner=True) + f.LikesFactory.create(content_object=project) + url_detail = reverse("projects-detail", args=(project.id,)) + url_like = reverse("projects-like", args=(project.id,)) + url_unlike = reverse("projects-unlike", args=(project.id,)) + + client.login(user) + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['total_fans'] == 0 + assert response.data['is_fan'] == False + + response = client.post(url_like) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['total_fans'] == 1 + assert response.data['is_fan'] == True + + response = client.post(url_unlike) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['total_fans'] == 0 + assert response.data['is_fan'] == False diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py index a9d4c1cd..1a5959e0 100644 --- a/tests/integration/test_users.py +++ b/tests/integration/test_users.py @@ -9,10 +9,10 @@ from .. import factories as f from taiga.base.utils import json from taiga.users import models -from taiga.users.serializers import FanSerializer, VotedSerializer +from taiga.users.serializers import LikedObjectSerializer, VotedObjectSerializer from taiga.auth.tokens import get_token_for_user from taiga.permissions.permissions import MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_PERMISSIONS -from taiga.users.services import get_watched_list, get_voted_list +from taiga.users.services import get_watched_list, get_voted_list, get_liked_list from easy_thumbnails.files import generate_all_aliases, get_thumbnailer @@ -377,6 +377,25 @@ def test_get_watched_list(): assert len(get_watched_list(fav_user, viewer_user, q="unexisting text")) == 0 +def test_get_liked_list(): + fan_user = f.UserFactory() + viewer_user = f.UserFactory() + + project = f.ProjectFactory(is_private=False, name="Testing project") + role = f.RoleFactory(project=project, permissions=["view_project", "view_us", "view_tasks", "view_issues"]) + membership = f.MembershipFactory(project=project, role=role, user=fan_user) + content_type = ContentType.objects.get_for_model(project) + f.LikeFactory(content_type=content_type, object_id=project.id, user=fan_user) + f.LikesFactory(content_type=content_type, object_id=project.id, count=1) + + assert len(get_liked_list(fan_user, viewer_user)) == 1 + assert len(get_liked_list(fan_user, viewer_user, type="project")) == 1 + assert len(get_liked_list(fan_user, viewer_user, type="unknown")) == 0 + + assert len(get_liked_list(fan_user, viewer_user, q="project")) == 1 + assert len(get_liked_list(fan_user, viewer_user, q="unexisting text")) == 0 + + def test_get_voted_list(): fav_user = f.UserFactory() viewer_user = f.UserFactory() @@ -384,9 +403,6 @@ def test_get_voted_list(): project = f.ProjectFactory(is_private=False, name="Testing project") role = f.RoleFactory(project=project, permissions=["view_project", "view_us", "view_tasks", "view_issues"]) membership = f.MembershipFactory(project=project, role=role, user=fav_user) - content_type = ContentType.objects.get_for_model(project) - f.VoteFactory(content_type=content_type, object_id=project.id, user=fav_user) - f.VotesFactory(content_type=content_type, object_id=project.id, count=1) user_story = f.UserStoryFactory(project=project, subject="Testing user story") content_type = ContentType.objects.get_for_model(user_story) @@ -403,8 +419,7 @@ def test_get_voted_list(): f.VoteFactory(content_type=content_type, object_id=issue.id, user=fav_user) f.VotesFactory(content_type=content_type, object_id=issue.id, count=1) - assert len(get_voted_list(fav_user, viewer_user)) == 4 - assert len(get_voted_list(fav_user, viewer_user, type="project")) == 1 + assert len(get_voted_list(fav_user, viewer_user)) == 3 assert len(get_voted_list(fav_user, viewer_user, type="userstory")) == 1 assert len(get_voted_list(fav_user, viewer_user, type="task")) == 1 assert len(get_voted_list(fav_user, viewer_user, type="issue")) == 1 @@ -423,7 +438,8 @@ def test_get_watched_list_valid_info_for_project(): project.add_watcher(fav_user) raw_project_watch_info = get_watched_list(fav_user, viewer_user)[0] - project_watch_info = FanSerializer(raw_project_watch_info).data + + project_watch_info = LikedObjectSerializer(raw_project_watch_info).data assert project_watch_info["type"] == "project" assert project_watch_info["id"] == project.id @@ -454,46 +470,46 @@ def test_get_watched_list_valid_info_for_project(): assert project_watch_info["assigned_to_photo"] == None -def test_get_voted_list_valid_info_for_project(): - fav_user = f.UserFactory() +def test_get_liked_list_valid_info(): + fan_user = f.UserFactory() viewer_user = f.UserFactory() project = f.ProjectFactory(is_private=False, name="Testing project", tags=['test', 'tag']) content_type = ContentType.objects.get_for_model(project) - vote = f.VoteFactory(content_type=content_type, object_id=project.id, user=fav_user) - f.VotesFactory(content_type=content_type, object_id=project.id, count=1) + like = f.LikeFactory(content_type=content_type, object_id=project.id, user=fan_user) + f.LikesFactory(content_type=content_type, object_id=project.id, count=1) - raw_project_vote_info = get_voted_list(fav_user, viewer_user)[0] - project_vote_info = FanSerializer(raw_project_vote_info).data + raw_project_like_info = get_liked_list(fan_user, viewer_user)[0] + project_like_info = LikedObjectSerializer(raw_project_like_info).data - assert project_vote_info["type"] == "project" - assert project_vote_info["id"] == project.id - assert project_vote_info["ref"] == None - assert project_vote_info["slug"] == project.slug - assert project_vote_info["name"] == project.name - assert project_vote_info["subject"] == None - assert project_vote_info["description"] == project.description - assert project_vote_info["assigned_to"] == None - assert project_vote_info["status"] == None - assert project_vote_info["status_color"] == None + assert project_like_info["type"] == "project" + assert project_like_info["id"] == project.id + assert project_like_info["ref"] == None + assert project_like_info["slug"] == project.slug + assert project_like_info["name"] == project.name + assert project_like_info["subject"] == None + assert project_like_info["description"] == project.description + assert project_like_info["assigned_to"] == None + assert project_like_info["status"] == None + assert project_like_info["status_color"] == None - tags_colors = {tc["name"]:tc["color"] for tc in project_vote_info["tags_colors"]} + tags_colors = {tc["name"]:tc["color"] for tc in project_like_info["tags_colors"]} assert "test" in tags_colors assert "tag" in tags_colors - assert project_vote_info["is_private"] == project.is_private + assert project_like_info["is_private"] == project.is_private - assert project_vote_info["is_fan"] == False - assert project_vote_info["is_watcher"] == False - assert project_vote_info["total_watchers"] == 0 - assert project_vote_info["total_fans"] == 1 - assert project_vote_info["project"] == None - assert project_vote_info["project_name"] == None - assert project_vote_info["project_slug"] == None - assert project_vote_info["project_is_private"] == None - assert project_vote_info["assigned_to_username"] == None - assert project_vote_info["assigned_to_full_name"] == None - assert project_vote_info["assigned_to_photo"] == None + assert project_like_info["is_fan"] == False + assert project_like_info["is_watcher"] == False + assert project_like_info["total_watchers"] == 0 + assert project_like_info["total_fans"] == 1 + assert project_like_info["project"] == None + assert project_like_info["project_name"] == None + assert project_like_info["project_slug"] == None + assert project_like_info["project_is_private"] == None + assert project_like_info["assigned_to_username"] == None + assert project_like_info["assigned_to_full_name"] == None + assert project_like_info["assigned_to_photo"] == None def test_get_watched_list_valid_info_for_not_project_types(): @@ -517,7 +533,7 @@ def test_get_watched_list_valid_info_for_not_project_types(): instance.add_watcher(fav_user) raw_instance_watch_info = get_watched_list(fav_user, viewer_user, type=object_type)[0] - instance_watch_info = VotedSerializer(raw_instance_watch_info).data + instance_watch_info = VotedObjectSerializer(raw_instance_watch_info).data assert instance_watch_info["type"] == object_type assert instance_watch_info["id"] == instance.id @@ -548,7 +564,7 @@ def test_get_watched_list_valid_info_for_not_project_types(): assert instance_watch_info["assigned_to_photo"] != "" -def test_get_voted_list_valid_info_for_not_project_types(): +def test_get_voted_list_valid_info(): fav_user = f.UserFactory() viewer_user = f.UserFactory() assigned_to_user = f.UserFactory() @@ -572,7 +588,7 @@ def test_get_voted_list_valid_info_for_not_project_types(): f.VotesFactory(content_type=content_type, object_id=instance.id, count=3) raw_instance_vote_info = get_voted_list(fav_user, viewer_user, type=object_type)[0] - instance_vote_info = VotedSerializer(raw_instance_vote_info).data + instance_vote_info = VotedObjectSerializer(raw_instance_vote_info).data assert instance_vote_info["type"] == object_type assert instance_vote_info["id"] == instance.id @@ -603,6 +619,87 @@ def test_get_voted_list_valid_info_for_not_project_types(): assert instance_vote_info["assigned_to_photo"] != "" +def test_get_watched_list_with_liked_and_voted_objects(client): + fav_user = f.UserFactory() + + project = f.ProjectFactory(is_private=False, name="Testing project") + role = f.RoleFactory(project=project, permissions=["view_project", "view_us", "view_tasks", "view_issues"]) + membership = f.MembershipFactory(project=project, role=role, user=fav_user) + project.add_watcher(fav_user) + content_type = ContentType.objects.get_for_model(project) + f.LikeFactory(content_type=content_type, object_id=project.id, user=fav_user) + + voted_elements_factories = { + "userstory": f.UserStoryFactory, + "task": f.TaskFactory, + "issue": f.IssueFactory + } + + for object_type in voted_elements_factories: + instance = voted_elements_factories[object_type](project=project) + content_type = ContentType.objects.get_for_model(instance) + instance.add_watcher(fav_user) + f.VoteFactory(content_type=content_type, object_id=instance.id, user=fav_user) + + client.login(fav_user) + url = reverse('users-watched', kwargs={"pk": fav_user.pk}) + response = client.get(url, content_type="application/json") + + for element_data in response.data: + #assert element_data["is_watcher"] == True + if element_data["type"] == "project": + assert element_data["is_fan"] == True + else: + assert element_data["is_voter"] == True + + +def test_get_liked_list_with_watched_objects(client): + fav_user = f.UserFactory() + + project = f.ProjectFactory(is_private=False, name="Testing project") + role = f.RoleFactory(project=project, permissions=["view_project", "view_us", "view_tasks", "view_issues"]) + membership = f.MembershipFactory(project=project, role=role, user=fav_user) + project.add_watcher(fav_user) + content_type = ContentType.objects.get_for_model(project) + f.LikeFactory(content_type=content_type, object_id=project.id, user=fav_user) + + client.login(fav_user) + url = reverse('users-liked', kwargs={"pk": fav_user.pk}) + response = client.get(url, content_type="application/json") + + element_data = response.data[0] + assert element_data["is_watcher"] == True + assert element_data["is_fan"] == True + + +def test_get_voted_list_with_watched_objects(client): + fav_user = f.UserFactory() + + project = f.ProjectFactory(is_private=False, name="Testing project") + role = f.RoleFactory(project=project, permissions=["view_project", "view_us", "view_tasks", "view_issues"]) + membership = f.MembershipFactory(project=project, role=role, user=fav_user) + + voted_elements_factories = { + "userstory": f.UserStoryFactory, + "task": f.TaskFactory, + "issue": f.IssueFactory + } + + for object_type in voted_elements_factories: + instance = voted_elements_factories[object_type](project=project) + content_type = ContentType.objects.get_for_model(instance) + instance.add_watcher(fav_user) + f.VoteFactory(content_type=content_type, object_id=instance.id, user=fav_user) + + client.login(fav_user) + url = reverse('users-voted', kwargs={"pk": fav_user.pk}) + response = client.get(url, content_type="application/json") + + for element_data in response.data: + assert element_data["is_watcher"] == True + assert element_data["is_voter"] == True + + def test_get_watched_list_permissions(): fav_user = f.UserFactory() viewer_unpriviliged_user = f.UserFactory() @@ -637,6 +734,33 @@ def test_get_watched_list_permissions(): assert len(get_watched_list(fav_user, viewer_unpriviliged_user)) == 4 +def test_get_liked_list_permissions(): + fan_user = f.UserFactory() + viewer_unpriviliged_user = f.UserFactory() + viewer_priviliged_user = f.UserFactory() + + project = f.ProjectFactory(is_private=True, name="Testing project") + role = f.RoleFactory(project=project, permissions=["view_project", "view_us", "view_tasks", "view_issues"]) + membership = f.MembershipFactory(project=project, role=role, user=viewer_priviliged_user) + content_type = ContentType.objects.get_for_model(project) + f.LikeFactory(content_type=content_type, object_id=project.id, user=fan_user) + f.LikesFactory(content_type=content_type, object_id=project.id, count=1) + + #If the project is private a viewer user without any permission shouldn' see + # any vote + assert len(get_liked_list(fan_user, viewer_unpriviliged_user)) == 0 + + #If the project is private but the viewer user has permissions the votes should + # be accesible + assert len(get_liked_list(fan_user, viewer_priviliged_user)) == 1 + + #If the project is private but has the required anon permissions the votes should + # be accesible by any user too + project.anon_permissions = ["view_project", "view_us", "view_tasks", "view_issues"] + project.save() + assert len(get_liked_list(fan_user, viewer_unpriviliged_user)) == 1 + + def test_get_voted_list_permissions(): fav_user = f.UserFactory() viewer_unpriviliged_user = f.UserFactory() @@ -645,9 +769,6 @@ def test_get_voted_list_permissions(): project = f.ProjectFactory(is_private=True, name="Testing project") role = f.RoleFactory(project=project, permissions=["view_project", "view_us", "view_tasks", "view_issues"]) membership = f.MembershipFactory(project=project, role=role, user=viewer_priviliged_user) - content_type = ContentType.objects.get_for_model(project) - f.VoteFactory(content_type=content_type, object_id=project.id, user=fav_user) - f.VotesFactory(content_type=content_type, object_id=project.id, count=1) user_story = f.UserStoryFactory(project=project, subject="Testing user story") content_type = ContentType.objects.get_for_model(user_story) @@ -670,10 +791,10 @@ def test_get_voted_list_permissions(): #If the project is private but the viewer user has permissions the votes should # be accesible - assert len(get_voted_list(fav_user, viewer_priviliged_user)) == 4 + assert len(get_voted_list(fav_user, viewer_priviliged_user)) == 3 #If the project is private but has the required anon permissions the votes should # be accesible by any user too project.anon_permissions = ["view_project", "view_us", "view_tasks", "view_issues"] project.save() - assert len(get_voted_list(fav_user, viewer_unpriviliged_user)) == 4 + assert len(get_voted_list(fav_user, viewer_unpriviliged_user)) == 3