From 3492b46cc9d19a85b8f9e630a224e51276ea37ef Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 18 Aug 2015 09:23:20 +0200 Subject: [PATCH] User favourites API --- taiga/users/api.py | 27 +++ taiga/users/permissions.py | 1 + taiga/users/serializers.py | 57 +++++- taiga/users/services.py | 171 ++++++++++++++++++ .../test_users_resources.py | 12 ++ tests/integration/test_users.py | 165 +++++++++++++++++ 6 files changed, 432 insertions(+), 1 deletion(-) diff --git a/taiga/users/api.py b/taiga/users/api.py index 0cd8f50c..ea5e5bdc 100644 --- a/taiga/users/api.py +++ b/taiga/users/api.py @@ -114,6 +114,33 @@ class UsersViewSet(ModelCrudViewSet): self.check_permissions(request, "stats", user) return response.Ok(services.get_stats_for_user(user, request.user)) + @detail_route(methods=["GET"]) + def favourites(self, request, *args, **kwargs): + for_user = get_object_or_404(models.User, **kwargs) + from_user = request.user + self.check_permissions(request, 'favourites', for_user) + filters = { + "type": request.GET.get("type", None), + "action": request.GET.get("action", None), + "q": request.GET.get("q", None), + } + + self.object_list = services.get_favourites_list(for_user, from_user, **filters) + page = self.paginate_queryset(self.object_list) + + extra_args = { + "many": True, + "user_votes": services.get_voted_content_for_user(request.user), + "user_watching": services.get_watched_content_for_user(request.user), + } + + if page is not None: + serializer = serializers.FavouriteSerializer(page.object_list, **extra_args) + else: + serializer = serializers.FavouriteSerializer(self.object_list, **extra_args) + + return response.Ok(serializer.data) + @list_route(methods=["POST"]) def password_recovery(self, request, pk=None): username_or_email = request.DATA.get('username', None) diff --git a/taiga/users/permissions.py b/taiga/users/permissions.py index bad16f1a..63c54751 100644 --- a/taiga/users/permissions.py +++ b/taiga/users/permissions.py @@ -47,6 +47,7 @@ class UserPermission(TaigaResourcePermission): starred_perms = AllowAny() change_email_perms = AllowAny() contacts_perms = AllowAny() + favourites_perms = AllowAny() class RolesPermission(TaigaResourcePermission): diff --git a/taiga/users/serializers.py b/taiga/users/serializers.py index 115462be..3158f5f8 100644 --- a/taiga/users/serializers.py +++ b/taiga/users/serializers.py @@ -19,11 +19,14 @@ from django.core.exceptions import ValidationError from django.utils.translation import ugettext_lazy as _ from taiga.base.api import serializers -from taiga.base.fields import PgArrayField +from taiga.base.fields import PgArrayField, TagsField + from taiga.projects.models import Project from .models import User, Role from .services import get_photo_or_gravatar_url, get_big_photo_or_gravatar_url +from collections import namedtuple + import re @@ -149,3 +152,55 @@ class ProjectRoleSerializer(serializers.ModelSerializer): model = Role fields = ('id', 'name', 'slug', 'order', 'computable') i18n_fields = ("name",) + + +###################################################### +## Favourite +###################################################### + + +class FavouriteSerializer(serializers.Serializer): + type = serializers.CharField() + action = serializers.CharField() + id = serializers.IntegerField() + ref = serializers.IntegerField() + slug = serializers.CharField() + subject = serializers.CharField() + tags = TagsField(default=[]) + project = serializers.IntegerField() + assigned_to = serializers.IntegerField() + total_watchers = serializers.IntegerField() + + is_voted = serializers.SerializerMethodField("get_is_voted") + is_watched = serializers.SerializerMethodField("get_is_watched") + + created_date = serializers.DateTimeField() + + project_name = serializers.CharField() + project_slug = serializers.CharField() + project_is_private = serializers.CharField() + + assigned_to_username = serializers.CharField() + assigned_to_full_name = serializers.CharField() + assigned_to_photo = serializers.SerializerMethodField("get_photo") + + total_votes = 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(FavouriteSerializer, self).__init__(*args, **kwargs) + + def get_is_voted(self, obj): + return obj["id"] in self.user_votes.get(obj["type"], []) + + def get_is_watched(self, obj): + return obj["id"] in self.user_watching.get(obj["type"], []) + + def get_photo(self, obj): + UserData = namedtuple("UserData", ["photo", "email"]) + user_data = UserData(photo=obj["assigned_to_photo"], email=obj.get("assigned_to_email") or "") + return get_photo_or_gravatar_url(user_data) diff --git a/taiga/users/services.py b/taiga/users/services.py index caf27226..992f4ae8 100644 --- a/taiga/users/services.py +++ b/taiga/users/services.py @@ -20,6 +20,7 @@ This model contains a domain logic for users application. from django.apps import apps from django.db.models import Q +from django.db import connection from django.conf import settings from django.utils.translation import ugettext as _ @@ -142,3 +143,173 @@ def get_stats_for_user(from_user, by_user): 'total_num_closed_userstories': total_num_closed_userstories, } return project_stats + + +def get_voted_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 voted by the user + """ + if user.is_anonymous(): + return {} + + user_votes = {} + for (ct_model, object_id) in user.votes.values_list("content_type__model", "object_id"): + list = user_votes.get(ct_model, []) + list.append(object_id) + user_votes[ct_model] = list + + return user_votes + + +def get_watched_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 watched by the user + """ + if user.is_anonymous(): + return {} + + user_watches = {} + for (ct_model, object_id) in user.watched.values_list("content_type__model", "object_id"): + list = user_watches.get(ct_model, []) + list.append(object_id) + user_watches[ct_model] = list + + return user_watches + + +def _build_favourites_sql_for_type(for_user, type, table_name, ref_column="ref", + project_column="project_id", assigned_to_column="assigned_to_id", + slug_column="slug", subject_column="subject"): + sql = """ + SELECT {table_name}.id AS id, {ref_column} AS ref, '{type}' AS type, 'watch' AS action, + tags, notifications_watched.object_id AS object_id, {table_name}.{project_column} AS project, + {slug_column} AS slug, {subject_column} AS subject, + notifications_watched.created_date, coalesce(watchers, 0) as total_watchers, coalesce(votes_votes.count, 0) total_votes, {assigned_to_column} AS assigned_to + FROM notifications_watched + INNER JOIN django_content_type + ON (notifications_watched.content_type_id = django_content_type.id AND django_content_type.model = '{type}') + INNER JOIN {table_name} + ON ({table_name}.id = notifications_watched.object_id) + LEFT JOIN (SELECT object_id, content_type_id, count(*) watchers FROM notifications_watched GROUP BY object_id, content_type_id) type_watchers + ON {table_name}.id = type_watchers.object_id AND django_content_type.id = type_watchers.content_type_id + LEFT JOIN votes_votes + ON ({table_name}.id = votes_votes.object_id AND django_content_type.id = votes_votes.content_type_id) + WHERE notifications_watched.user_id = {for_user_id} + UNION + SELECT {table_name}.id AS id, {ref_column} AS ref, '{type}' AS type, 'vote' AS action, + tags, votes_vote.object_id AS object_id, {table_name}.{project_column} AS project, + {slug_column} AS slug, {subject_column} AS subject, + votes_vote.created_date, coalesce(watchers, 0) as total_watchers, votes_votes.count total_votes, {assigned_to_column} AS assigned_to + FROM votes_vote + INNER JOIN django_content_type + ON (votes_vote.content_type_id = django_content_type.id AND django_content_type.model = '{type}') + INNER JOIN {table_name} + ON ({table_name}.id = votes_vote.object_id) + LEFT JOIN (SELECT object_id, content_type_id, count(*) watchers FROM notifications_watched GROUP BY object_id, content_type_id) type_watchers + ON {table_name}.id = type_watchers.object_id AND django_content_type.id = type_watchers.content_type_id + LEFT JOIN votes_votes + ON ({table_name}.id = votes_votes.object_id AND django_content_type.id = votes_votes.content_type_id) + WHERE votes_vote.user_id = {for_user_id} + """ + sql = sql.format(for_user_id=for_user.id, type=type, table_name=table_name, + ref_column = ref_column, project_column=project_column, + assigned_to_column=assigned_to_column, slug_column=slug_column, + subject_column=subject_column) + return sql + + +def get_favourites_list(for_user, from_user, type=None, action=None, q=None): + filters_sql = "" + and_needed = False + + if type: + filters_sql += " AND type = '{type}' ".format(type=type) + + if action: + filters_sql += " AND action = '{action}' ".format(action=action) + + if q: + filters_sql += " AND to_tsvector(coalesce(subject, '')) @@ plainto_tsquery('{q}') ".format(q=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.slug as project_slug, projects_project.is_private as project_is_private, + 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} + UNION + {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( + (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))) + OR (entities.type = 'project' AND 'view_project' = ANY (array_cat(users_role.permissions, projects_project.anon_permissions))) + ) + )) + -- END Permissions checking + {filters_sql} + + ORDER BY entities.created_date; + """ + + 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_favourites_sql_for_type(for_user, "userstory", "userstories_userstory", slug_column="null"), + tasks_sql=_build_favourites_sql_for_type(for_user, "task", "tasks_task", slug_column="null"), + issues_sql=_build_favourites_sql_for_type(for_user, "issue", "issues_issue", slug_column="null"), + projects_sql=_build_favourites_sql_for_type(for_user, "project", "projects_project", + ref_column="null", + project_column="id", + assigned_to_column="null", + subject_column="projects_project.name") + ) + + 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/integration/resources_permissions/test_users_resources.py b/tests/integration/resources_permissions/test_users_resources.py index fada3a72..761439d1 100644 --- a/tests/integration/resources_permissions/test_users_resources.py +++ b/tests/integration/resources_permissions/test_users_resources.py @@ -287,3 +287,15 @@ def test_user_action_change_email(client, data): after_each_request() results = helper_test_http_method(client, 'post', url, patch_data, users, after_each_request=after_each_request) assert results == [204, 204, 204] + + +def test_user_list_votes(client, data): + url = reverse('users-favourites', 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_users.py b/tests/integration/test_users.py index 2d3f1664..b658045f 100644 --- a/tests/integration/test_users.py +++ b/tests/integration/test_users.py @@ -1,6 +1,7 @@ import pytest from tempfile import NamedTemporaryFile +from django.contrib.contenttypes.models import ContentType from django.core.urlresolvers import reverse from .. import factories as f @@ -9,6 +10,7 @@ from taiga.base.utils import json from taiga.users import models 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_favourites_list pytestmark = pytest.mark.django_db @@ -249,3 +251,166 @@ def test_list_contacts_public_projects(client): response_content = response.data assert len(response_content) == 1 assert response_content[0]["id"] == user_2.id + + +def test_get_favourites_list(): + fav_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=fav_user) + project.add_watcher(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") + user_story.add_watcher(fav_user) + content_type = ContentType.objects.get_for_model(user_story) + f.VoteFactory(content_type=content_type, object_id=user_story.id, user=fav_user) + f.VotesFactory(content_type=content_type, object_id=user_story.id, count=1) + + task = f.TaskFactory(project=project, subject="Testing task") + task.add_watcher(fav_user) + content_type = ContentType.objects.get_for_model(task) + f.VoteFactory(content_type=content_type, object_id=task.id, user=fav_user) + f.VotesFactory(content_type=content_type, object_id=task.id, count=1) + + issue = f.IssueFactory(project=project, subject="Testing issue") + issue.add_watcher(fav_user) + content_type = ContentType.objects.get_for_model(issue) + 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_favourites_list(fav_user, viewer_user)) == 8 + assert len(get_favourites_list(fav_user, viewer_user, type="project")) == 2 + assert len(get_favourites_list(fav_user, viewer_user, type="userstory")) == 2 + assert len(get_favourites_list(fav_user, viewer_user, type="task")) == 2 + assert len(get_favourites_list(fav_user, viewer_user, type="issue")) == 2 + assert len(get_favourites_list(fav_user, viewer_user, type="unknown")) == 0 + + assert len(get_favourites_list(fav_user, viewer_user, action="watch")) == 4 + assert len(get_favourites_list(fav_user, viewer_user, action="vote")) == 4 + + assert len(get_favourites_list(fav_user, viewer_user, q="issue")) == 2 + assert len(get_favourites_list(fav_user, viewer_user, q="unexisting text")) == 0 + + +def test_get_favourites_list_valid_info_for_project(): + fav_user = f.UserFactory() + viewer_user = f.UserFactory() + watcher_user = f.UserFactory() + + project = f.ProjectFactory(is_private=False, name="Testing project") + project.add_watcher(watcher_user) + 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) + + project_vote_info = get_favourites_list(fav_user, viewer_user)[0] + assert project_vote_info["type"] == "project" + assert project_vote_info["action"] == "vote" + assert project_vote_info["id"] == project.id + assert project_vote_info["ref"] == None + assert project_vote_info["slug"] == project.slug + assert project_vote_info["subject"] == project.name + assert project_vote_info["tags"] == project.tags + assert project_vote_info["project"] == project.id + assert project_vote_info["assigned_to"] == None + assert project_vote_info["total_watchers"] == 1 + assert project_vote_info["created_date"] == vote.created_date + assert project_vote_info["project_name"] == project.name + assert project_vote_info["project_slug"] == project.slug + assert project_vote_info["project_is_private"] == project.is_private + 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_vote_info["assigned_to_email"] == None + assert project_vote_info["total_votes"] == 1 + + +def test_get_favourites_list_valid_info_for_not_project_types(): + fav_user = f.UserFactory() + viewer_user = f.UserFactory() + watcher_user = f.UserFactory() + assigned_to_user = f.UserFactory() + + project = f.ProjectFactory(is_private=False, name="Testing project") + + factories = { + "userstory": f.UserStoryFactory, + "task": f.TaskFactory, + "issue": f.IssueFactory + } + + for object_type in factories: + instance = factories[object_type](project=project, + subject="Testing", + tags=["test1", "test2"], + assigned_to=assigned_to_user) + + instance.add_watcher(watcher_user) + content_type = ContentType.objects.get_for_model(instance) + vote = f.VoteFactory(content_type=content_type, object_id=instance.id, user=fav_user) + f.VotesFactory(content_type=content_type, object_id=instance.id, count=3) + + instance_vote_info = get_favourites_list(fav_user, viewer_user, type=object_type)[0] + assert instance_vote_info["type"] == object_type + assert instance_vote_info["action"] == "vote" + assert instance_vote_info["id"] == instance.id + assert instance_vote_info["ref"] == instance.ref + assert instance_vote_info["slug"] == None + assert instance_vote_info["subject"] == instance.subject + assert instance_vote_info["tags"] == instance.tags + assert instance_vote_info["project"] == instance.project.id + assert instance_vote_info["assigned_to"] == assigned_to_user.id + assert instance_vote_info["total_watchers"] == 1 + assert instance_vote_info["created_date"] == vote.created_date + assert instance_vote_info["project_name"] == instance.project.name + assert instance_vote_info["project_slug"] == instance.project.slug + assert instance_vote_info["project_is_private"] == instance.project.is_private + assert instance_vote_info["assigned_to_username"] == assigned_to_user.username + assert instance_vote_info["assigned_to_full_name"] == assigned_to_user.full_name + assert instance_vote_info["assigned_to_photo"] == '' + assert instance_vote_info["assigned_to_email"] == assigned_to_user.email + assert instance_vote_info["total_votes"] == 3 + + +def test_get_favourites_list_permissions(): + fav_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.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) + f.VoteFactory(content_type=content_type, object_id=user_story.id, user=fav_user) + f.VotesFactory(content_type=content_type, object_id=user_story.id, count=1) + + task = f.TaskFactory(project=project, subject="Testing task") + content_type = ContentType.objects.get_for_model(task) + f.VoteFactory(content_type=content_type, object_id=task.id, user=fav_user) + f.VotesFactory(content_type=content_type, object_id=task.id, count=1) + + issue = f.IssueFactory(project=project, subject="Testing issue") + content_type = ContentType.objects.get_for_model(issue) + 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) + + #If the project is private a viewer user without any permission shouldn' see any vote + assert len(get_favourites_list(fav_user, viewer_unpriviliged_user)) == 0 + + #If the project is private but the viewer user has permissions the votes should be accesible + assert len(get_favourites_list(fav_user, viewer_priviliged_user)) == 4 + + #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_favourites_list(fav_user, viewer_unpriviliged_user)) == 4