From 44eee5212a082c0833d27aff8dac9f9f5ba22860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 5 Aug 2015 22:22:05 +0200 Subject: [PATCH 1/5] Improve votes module --- CHANGELOG.md | 4 +- taiga/permissions/permissions.py | 12 +- taiga/projects/api.py | 42 ++---- taiga/projects/issues/api.py | 66 ++-------- taiga/projects/issues/permissions.py | 8 +- taiga/projects/issues/serializers.py | 10 +- taiga/projects/permissions.py | 14 +- taiga/projects/serializers.py | 9 +- taiga/projects/tasks/api.py | 16 ++- taiga/projects/tasks/permissions.py | 12 +- taiga/projects/tasks/serializers.py | 4 +- taiga/projects/userstories/api.py | 17 ++- taiga/projects/userstories/permissions.py | 9 ++ taiga/projects/userstories/serializers.py | 4 +- .../migrations/0002_auto_20150805_1600.py | 35 +++++ taiga/projects/votes/mixins/__init__.py | 0 taiga/projects/votes/mixins/serializers.py | 37 ++++++ taiga/projects/votes/mixins/viewsets.py | 114 ++++++++++++++++ taiga/projects/votes/models.py | 10 +- taiga/projects/votes/utils.py | 38 +++++- taiga/routers.py | 13 +- taiga/users/api.py | 9 -- .../test_issues_resources.py | 27 ++-- .../test_projects_resource.py | 71 +++++----- .../test_tasks_resources.py | 91 +++++++++++++ .../test_userstories_resources.py | 90 +++++++++++++ tests/integration/test_star_projects.py | 123 ++++++++++++++++++ tests/integration/test_stars.py | 115 ---------------- tests/integration/test_vote_issues.py | 45 ++++++- tests/integration/test_vote_tasks.py | 123 ++++++++++++++++++ tests/integration/test_vote_userstories.py | 122 +++++++++++++++++ tests/utils.py | 5 +- 32 files changed, 975 insertions(+), 320 deletions(-) create mode 100644 taiga/projects/votes/migrations/0002_auto_20150805_1600.py create mode 100644 taiga/projects/votes/mixins/__init__.py create mode 100644 taiga/projects/votes/mixins/serializers.py create mode 100644 taiga/projects/votes/mixins/viewsets.py create mode 100644 tests/integration/test_star_projects.py delete mode 100644 tests/integration/test_stars.py create mode 100644 tests/integration/test_vote_tasks.py create mode 100644 tests/integration/test_vote_userstories.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 04307eb6..bb640e86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,15 @@ ## 1.9.0 ??? (unreleased) ### Features -- Add a "field type" property for custom fields: 'text' and 'multiline text' right now (thanks to [@artlepool](https://github.com/artlepool)) +- Add a "field type" property for custom fields: 'text' and 'multiline text' right now (thanks to [@artlepool](https://github.com/artlepool)). - Allow multiple actions in the commit messages. - Now every user that coments USs, Issues or Tasks will be involved in it (add author to the watchers list). - Fix the compatibility with BitBucket webhooks and add issues and issues comments integration. - Add custom videoconference system. - Add support for comments in the Gitlab webhooks integration. - Now profile timelines only show content about the objects (US/Tasks/Issues/Wiki pages) you are involved. +- US, tasks and Issues can be upvoted or downvoted and the voters list can be obtained. +- Project can be starred or unstarred and the fans list can be obtained. - i18n. - Add polish (pl) translation. - Add portuguese (Brazil) (pt_BR) translation. diff --git a/taiga/permissions/permissions.py b/taiga/permissions/permissions.py index b2e516f4..e711c77a 100644 --- a/taiga/permissions/permissions.py +++ b/taiga/permissions/permissions.py @@ -32,7 +32,6 @@ USER_PERMISSIONS = [ ('view_milestones', _('View milestones')), ('view_us', _('View user stories')), ('view_issues', _('View issues')), - ('vote_issues', _('Vote issues')), ('view_tasks', _('View tasks')), ('view_wiki_pages', _('View wiki pages')), ('view_wiki_links', _('View wiki links')), @@ -41,15 +40,20 @@ USER_PERMISSIONS = [ ('add_comments_to_us', _('Add comments to user stories')), ('add_comments_to_task', _('Add comments to tasks')), ('add_issue', _('Add issues')), - ('add_comments_issue', _('Add comments to issues')), + ('add_comments_to_issue', _('Add comments to issues')), ('add_wiki_page', _('Add wiki page')), ('modify_wiki_page', _('Modify wiki page')), ('add_wiki_link', _('Add wiki link')), ('modify_wiki_link', _('Modify wiki link')), + ('star_project', _('Star project')), + ('vote_us', _('Vote user story')), + ('vote_task', _('Vote task')), + ('vote_issue', _('Vote issue')), ] MEMBERS_PERMISSIONS = [ ('view_project', _('View project')), + ('star_project', _('Star project')), # Milestone permissions ('view_milestones', _('View milestones')), ('add_milestone', _('Add milestone')), @@ -60,17 +64,19 @@ MEMBERS_PERMISSIONS = [ ('add_us', _('Add user story')), ('modify_us', _('Modify user story')), ('delete_us', _('Delete user story')), + ('vote_us', _('Vote user story')), # Task permissions ('view_tasks', _('View tasks')), ('add_task', _('Add task')), ('modify_task', _('Modify task')), ('delete_task', _('Delete task')), + ('vote_task', _('Vote task')), # Issue permissions ('view_issues', _('View issues')), - ('vote_issues', _('Vote issues')), ('add_issue', _('Add issue')), ('modify_issue', _('Modify issue')), ('delete_issue', _('Delete issue')), + ('vote_issue', _('Vote issue')), # Wiki page permissions ('view_wiki_pages', _('View wiki pages')), ('add_wiki_page', _('Add wiki page')), diff --git a/taiga/projects/api.py b/taiga/projects/api.py index cd70fbf3..9ff9be8c 100644 --- a/taiga/projects/api.py +++ b/taiga/projects/api.py @@ -44,15 +44,14 @@ from . import models from . import permissions from . import services -from .votes import serializers as votes_serializers -from .votes import services as votes_service -from .votes.utils import attach_votescount_to_queryset +from .votes.mixins.viewsets import StarredResourceMixin, VotersViewSetMixin ###################################################### ## Project ###################################################### -class ProjectViewSet(HistoryResourceMixin, ModelCrudViewSet): +class ProjectViewSet(StarredResourceMixin, HistoryResourceMixin, ModelCrudViewSet): + queryset = models.Project.objects.all() serializer_class = serializers.ProjectDetailSerializer admin_serializer_class = serializers.ProjectDetailAdminSerializer list_serializer_class = serializers.ProjectSerializer @@ -61,6 +60,10 @@ class ProjectViewSet(HistoryResourceMixin, ModelCrudViewSet): filter_fields = (('member', 'members'),) order_by_fields = ("memberships__user_order",) + def get_queryset(self): + qs = super().get_queryset() + return self.attach_votes_attrs_to_queryset(qs) + @list_route(methods=["POST"]) def bulk_update_order(self, request, **kwargs): if self.request.user.is_anonymous(): @@ -74,10 +77,6 @@ class ProjectViewSet(HistoryResourceMixin, ModelCrudViewSet): services.update_projects_order_in_bulk(data, "user_order", request.user) return response.NoContent(data=None) - def get_queryset(self): - qs = models.Project.objects.all() - return attach_votescount_to_queryset(qs, as_field="stars_count") - def get_serializer_class(self): if self.action == "list": return self.list_serializer_class @@ -166,29 +165,6 @@ class ProjectViewSet(HistoryResourceMixin, ModelCrudViewSet): self.check_permissions(request, "tags_colors", project) return response.Ok(dict(project.tags_colors)) - @detail_route(methods=["POST"]) - def star(self, request, pk=None): - project = self.get_object() - self.check_permissions(request, "star", project) - votes_service.add_vote(project, user=request.user) - return response.Ok() - - @detail_route(methods=["POST"]) - def unstar(self, request, pk=None): - project = self.get_object() - self.check_permissions(request, "unstar", project) - votes_service.remove_vote(project, user=request.user) - return response.Ok() - - @detail_route(methods=["GET"]) - def fans(self, request, pk=None): - project = self.get_object() - self.check_permissions(request, "fans", project) - - voters = votes_service.get_voters(project) - voters_data = votes_serializers.VoterSerializer(voters, many=True) - return response.Ok(voters_data.data) - @detail_route(methods=["POST"]) def create_template(self, request, **kwargs): template_name = request.DATA.get('template_name', None) @@ -287,6 +263,10 @@ class ProjectViewSet(HistoryResourceMixin, ModelCrudViewSet): return response.NoContent() +class ProjectFansViewSet(VotersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.ProjectFansPermission,) + resource_model = models.Project + ###################################################### ## Custom values for selectors diff --git a/taiga/projects/issues/api.py b/taiga/projects/issues/api.py index ee587e8c..6fc78ce3 100644 --- a/taiga/projects/issues/api.py +++ b/taiga/projects/issues/api.py @@ -16,7 +16,7 @@ from django.utils.translation import ugettext as _ from django.db.models import Q -from django.http import Http404, HttpResponse +from django.http import HttpResponse from taiga.base import filters from taiga.base import exceptions as exc @@ -33,16 +33,17 @@ from taiga.projects.history.mixins import HistoryResourceMixin from taiga.projects.models import Project, IssueStatus, Severity, Priority, IssueType from taiga.projects.milestones.models import Milestone -from taiga.projects.votes.utils import attach_votescount_to_queryset -from taiga.projects.votes import services as votes_service -from taiga.projects.votes import serializers as votes_serializers +from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin + from . import models from . import services from . import permissions from . import serializers -class IssueViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, ModelCrudViewSet): +class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin, + ModelCrudViewSet): + queryset = models.Issue.objects.all() permission_classes = (permissions.IssuePermission, ) filter_backends = (filters.CanViewIssuesFilterBackend, filters.OwnersFilter, @@ -139,10 +140,9 @@ class IssueViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, return super().update(request, *args, **kwargs) def get_queryset(self): - qs = models.Issue.objects.all() + qs = super().get_queryset() qs = qs.prefetch_related("attachments") - qs = attach_votescount_to_queryset(qs, as_field="votes_count") - return qs + return self.attach_votes_attrs_to_queryset(qs) def pre_save(self, obj): if not obj.id: @@ -237,51 +237,7 @@ class IssueViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, return response.BadRequest(serializer.errors) - @detail_route(methods=['post']) - def upvote(self, request, pk=None): - issue = get_object_or_404(models.Issue, pk=pk) - self.check_permissions(request, 'upvote', issue) - - votes_service.add_vote(issue, user=request.user) - return response.Ok() - - @detail_route(methods=['post']) - def downvote(self, request, pk=None): - issue = get_object_or_404(models.Issue, pk=pk) - - self.check_permissions(request, 'downvote', issue) - - votes_service.remove_vote(issue, user=request.user) - return response.Ok() - - -class VotersViewSet(ModelListViewSet): - serializer_class = votes_serializers.VoterSerializer - list_serializer_class = votes_serializers.VoterSerializer - permission_classes = (permissions.IssueVotersPermission, ) - - def retrieve(self, request, *args, **kwargs): - pk = kwargs.get("pk", None) - issue_id = kwargs.get("issue_id", None) - issue = get_object_or_404(models.Issue, pk=issue_id) - - self.check_permissions(request, 'retrieve', issue) - - try: - self.object = votes_service.get_voters(issue).get(pk=pk) - except User.DoesNotExist: - raise Http404 - - serializer = self.get_serializer(self.object) - return response.Ok(serializer.data) - - def list(self, request, *args, **kwargs): - issue_id = kwargs.get("issue_id", None) - issue = get_object_or_404(models.Issue, pk=issue_id) - self.check_permissions(request, 'list', issue) - return super().list(request, *args, **kwargs) - - def get_queryset(self): - issue = models.Issue.objects.get(pk=self.kwargs.get("issue_id")) - return votes_service.get_voters(issue) +class IssueVotersViewSet(VotersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.IssueVotersPermission,) + resource_model = models.Issue diff --git a/taiga/projects/issues/permissions.py b/taiga/projects/issues/permissions.py index 076b57a0..423866bc 100644 --- a/taiga/projects/issues/permissions.py +++ b/taiga/projects/issues/permissions.py @@ -31,10 +31,10 @@ class IssuePermission(TaigaResourcePermission): list_perms = AllowAny() filters_data_perms = AllowAny() csv_perms = AllowAny() - upvote_perms = IsAuthenticated() & HasProjectPerm('vote_issues') - downvote_perms = IsAuthenticated() & HasProjectPerm('vote_issues') bulk_create_perms = HasProjectPerm('add_issue') delete_comment_perms= HasProjectPerm('modify_issue') + upvote_perms = IsAuthenticated() & HasProjectPerm('view_issues') + downvote_perms = IsAuthenticated() & HasProjectPerm('view_issues') class HasIssueIdUrlParam(PermissionComponent): @@ -49,8 +49,4 @@ class IssueVotersPermission(TaigaResourcePermission): enought_perms = IsProjectOwner() | IsSuperUser() global_perms = None retrieve_perms = HasProjectPerm('view_issues') - create_perms = HasProjectPerm('add_issue') - update_perms = HasProjectPerm('modify_issue') - partial_update_perms = HasProjectPerm('modify_issue') - destroy_perms = HasProjectPerm('delete_issue') list_perms = HasProjectPerm('view_issues') diff --git a/taiga/projects/issues/serializers.py b/taiga/projects/issues/serializers.py index 2b2723dd..76023cf0 100644 --- a/taiga/projects/issues/serializers.py +++ b/taiga/projects/issues/serializers.py @@ -19,17 +19,18 @@ from taiga.base.fields import TagsField from taiga.base.fields import PgArrayField from taiga.base.neighbors import NeighborsSerializerMixin - from taiga.mdrender.service import render as mdrender from taiga.projects.validators import ProjectExistsValidator from taiga.projects.notifications.validators import WatchersValidator from taiga.projects.serializers import BasicIssueStatusSerializer +from taiga.projects.votes.mixins.serializers import VotedResourceSerializerMixin + from taiga.users.serializers import UserBasicInfoSerializer from . import models -class IssueSerializer(WatchersValidator, serializers.ModelSerializer): +class IssueSerializer(WatchersValidator, VotedResourceSerializerMixin, serializers.ModelSerializer): tags = TagsField(required=False) external_reference = PgArrayField(required=False) is_closed = serializers.Field(source="is_closed") @@ -37,7 +38,6 @@ class IssueSerializer(WatchersValidator, serializers.ModelSerializer): generated_user_stories = serializers.SerializerMethodField("get_generated_user_stories") blocked_note_html = serializers.SerializerMethodField("get_blocked_note_html") description_html = serializers.SerializerMethodField("get_description_html") - votes = serializers.SerializerMethodField("get_votes_number") status_extra_info = BasicIssueStatusSerializer(source="status", required=False, read_only=True) assigned_to_extra_info = UserBasicInfoSerializer(source="assigned_to", required=False, read_only=True) owner_extra_info = UserBasicInfoSerializer(source="owner", required=False, read_only=True) @@ -59,10 +59,6 @@ class IssueSerializer(WatchersValidator, serializers.ModelSerializer): def get_description_html(self, obj): return mdrender(obj.project, obj.description) - def get_votes_number(self, obj): - # The "votes_count" attribute is attached in the get_queryset of the viewset. - return getattr(obj, "votes_count", 0) - class IssueListSerializer(IssueSerializer): class Meta: diff --git a/taiga/projects/permissions.py b/taiga/projects/permissions.py index ff152e58..236bc182 100644 --- a/taiga/projects/permissions.py +++ b/taiga/projects/permissions.py @@ -54,19 +54,25 @@ class ProjectPermission(TaigaResourcePermission): list_perms = AllowAny() stats_perms = HasProjectPerm('view_project') member_stats_perms = HasProjectPerm('view_project') + issues_stats_perms = HasProjectPerm('view_project') regenerate_userstories_csv_uuid_perms = IsProjectOwner() regenerate_issues_csv_uuid_perms = IsProjectOwner() regenerate_tasks_csv_uuid_perms = IsProjectOwner() - star_perms = IsAuthenticated() - unstar_perms = IsAuthenticated() - issues_stats_perms = HasProjectPerm('view_project') tags_perms = HasProjectPerm('view_project') tags_colors_perms = HasProjectPerm('view_project') - fans_perms = HasProjectPerm('view_project') + star_perms = IsAuthenticated() & HasProjectPerm('view_project') + unstar_perms = IsAuthenticated() & HasProjectPerm('view_project') create_template_perms = IsSuperUser() leave_perms = CanLeaveProject() +class ProjectFansPermission(TaigaResourcePermission): + enought_perms = IsProjectOwner() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_project') + list_perms = HasProjectPerm('view_project') + + class MembershipPermission(TaigaResourcePermission): retrieve_perms = HasProjectPerm('view_project') create_perms = IsProjectOwner() diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index bc1de975..a7e96834 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.py @@ -40,7 +40,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 StarredResourceSerializerMixin ###################################################### ## Custom values for selectors @@ -305,11 +305,10 @@ class ProjectMemberSerializer(serializers.ModelSerializer): ## Projects ###################################################### -class ProjectSerializer(serializers.ModelSerializer): +class ProjectSerializer(StarredResourceSerializerMixin, serializers.ModelSerializer): tags = TagsField(default=[], required=False) anon_permissions = PgArrayField(required=False) public_permissions = PgArrayField(required=False) - stars = serializers.SerializerMethodField("get_stars_number") my_permissions = serializers.SerializerMethodField("get_my_permissions") i_am_owner = serializers.SerializerMethodField("get_i_am_owner") tags_colors = TagsColorsField(required=False) @@ -321,10 +320,6 @@ class ProjectSerializer(serializers.ModelSerializer): exclude = ("last_us_ref", "last_task_ref", "last_issue_ref", "issues_csv_uuid", "tasks_csv_uuid", "userstories_csv_uuid") - def get_stars_number(self, obj): - # The "stars_count" attribute is attached in the get_queryset of the viewset. - return getattr(obj, "stars_count", 0) - def get_my_permissions(self, obj): if "request" in self.context: return get_user_project_permissions(self.context["request"].user, obj) diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py index 702d0a58..e2262632 100644 --- a/taiga/projects/tasks/api.py +++ b/taiga/projects/tasks/api.py @@ -20,13 +20,14 @@ from taiga.base.api.utils import get_object_or_404 from taiga.base import filters, response from taiga.base import exceptions as exc from taiga.base.decorators import list_route -from taiga.base.api import ModelCrudViewSet +from taiga.base.api import ModelCrudViewSet, ModelListViewSet from taiga.projects.models import Project, TaskStatus from django.http import HttpResponse from taiga.projects.notifications.mixins import WatchedResourceMixin from taiga.projects.history.mixins import HistoryResourceMixin from taiga.projects.occ import OCCResourceMixin +from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin from . import models @@ -35,8 +36,9 @@ from . import serializers from . import services -class TaskViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, ModelCrudViewSet): - model = models.Task +class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin, + ModelCrudViewSet): + queryset = models.Task.objects.all() permission_classes = (permissions.TaskPermission,) filter_backends = (filters.CanViewTasksFilterBackend,) filter_fields = ["user_story", "milestone", "project", "assigned_to", @@ -82,6 +84,9 @@ class TaskViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, return super().update(request, *args, **kwargs) + def get_queryset(self): + qs = super().get_queryset() + return self.attach_votes_attrs_to_queryset(qs) def pre_save(self, obj): if obj.user_story: @@ -165,3 +170,8 @@ class TaskViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, @list_route(methods=["POST"]) def bulk_update_us_order(self, request, **kwargs): return self._bulk_update_order("us_order", request, **kwargs) + + +class TaskVotersViewSet(VotersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.TaskVotersPermission,) + resource_model = models.Task diff --git a/taiga/projects/tasks/permissions.py b/taiga/projects/tasks/permissions.py index 2c1fd7b0..af5fbd49 100644 --- a/taiga/projects/tasks/permissions.py +++ b/taiga/projects/tasks/permissions.py @@ -15,7 +15,8 @@ # along with this program. If not, see . from taiga.base.api.permissions import (TaigaResourcePermission, HasProjectPerm, - IsProjectOwner, AllowAny, IsSuperUser) + IsAuthenticated, IsProjectOwner, AllowAny, + IsSuperUser) class TaskPermission(TaigaResourcePermission): @@ -30,3 +31,12 @@ class TaskPermission(TaigaResourcePermission): csv_perms = AllowAny() bulk_create_perms = HasProjectPerm('add_task') bulk_update_order_perms = HasProjectPerm('modify_task') + upvote_perms = IsAuthenticated() & HasProjectPerm('view_tasks') + downvote_perms = IsAuthenticated() & HasProjectPerm('view_tasks') + + +class TaskVotersPermission(TaigaResourcePermission): + enought_perms = IsProjectOwner() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_tasks') + list_perms = HasProjectPerm('view_tasks') diff --git a/taiga/projects/tasks/serializers.py b/taiga/projects/tasks/serializers.py index 6f64e2d5..3df25a77 100644 --- a/taiga/projects/tasks/serializers.py +++ b/taiga/projects/tasks/serializers.py @@ -27,12 +27,14 @@ from taiga.projects.milestones.validators import SprintExistsValidator from taiga.projects.tasks.validators import TaskExistsValidator from taiga.projects.notifications.validators import WatchersValidator from taiga.projects.serializers import BasicTaskStatusSerializerSerializer +from taiga.projects.votes.mixins.serializers import VotedResourceSerializerMixin + from taiga.users.serializers import UserBasicInfoSerializer from . import models -class TaskSerializer(WatchersValidator, serializers.ModelSerializer): +class TaskSerializer(WatchersValidator, VotedResourceSerializerMixin, serializers.ModelSerializer): tags = TagsField(required=False, default=[]) external_reference = PgArrayField(required=False) comment = serializers.SerializerMethodField("get_comment") diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index b364a53c..8ae11d53 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -28,15 +28,15 @@ from taiga.base import exceptions as exc from taiga.base import response from taiga.base import status from taiga.base.decorators import list_route -from taiga.base.api import ModelCrudViewSet +from taiga.base.api import ModelCrudViewSet, ModelListViewSet from taiga.base.api.utils import get_object_or_404 from taiga.projects.notifications.mixins import WatchedResourceMixin from taiga.projects.history.mixins import HistoryResourceMixin from taiga.projects.occ import OCCResourceMixin - from taiga.projects.models import Project, UserStoryStatus from taiga.projects.history.services import take_snapshot +from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin from . import models from . import permissions @@ -44,8 +44,9 @@ from . import serializers from . import services -class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, ModelCrudViewSet): - model = models.UserStory +class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin, + ModelCrudViewSet): + queryset = models.UserStory.objects.all() permission_classes = (permissions.UserStoryPermission,) filter_backends = (filters.CanViewUsFilterBackend, filters.OwnersFilter, @@ -109,13 +110,13 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi def get_queryset(self): - qs = self.model.objects.all() + qs = super().get_queryset() qs = qs.prefetch_related("role_points", "role_points__points", "role_points__role", "watchers") qs = qs.select_related("milestone", "project") - return qs + return self.attach_votes_attrs_to_queryset(qs) def pre_save(self, obj): # This is very ugly hack, but having @@ -264,3 +265,7 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi self.send_notifications(self.object.generated_from_issue, history) return response + +class UserStoryVotersViewSet(VotersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.UserStoryVotersPermission,) + resource_model = models.UserStory diff --git a/taiga/projects/userstories/permissions.py b/taiga/projects/userstories/permissions.py index 3aa548db..00659141 100644 --- a/taiga/projects/userstories/permissions.py +++ b/taiga/projects/userstories/permissions.py @@ -30,3 +30,12 @@ class UserStoryPermission(TaigaResourcePermission): csv_perms = AllowAny() bulk_create_perms = IsAuthenticated() & (HasProjectPerm('add_us_to_project') | HasProjectPerm('add_us')) bulk_update_order_perms = HasProjectPerm('modify_us') + upvote_perms = IsAuthenticated() & HasProjectPerm('view_us') + downvote_perms = IsAuthenticated() & HasProjectPerm('view_us') + + +class UserStoryVotersPermission(TaigaResourcePermission): + enought_perms = IsProjectOwner() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_us') + list_perms = HasProjectPerm('view_us') diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py index a23cd63c..6c3a76cd 100644 --- a/taiga/projects/userstories/serializers.py +++ b/taiga/projects/userstories/serializers.py @@ -27,6 +27,8 @@ from taiga.projects.validators import UserStoryStatusExistsValidator from taiga.projects.userstories.validators import UserStoryExistsValidator from taiga.projects.notifications.validators import WatchersValidator from taiga.projects.serializers import BasicUserStoryStatusSerializer +from taiga.projects.votes.mixins.serializers import VotedResourceSerializerMixin + from taiga.users.serializers import UserBasicInfoSerializer from . import models @@ -42,7 +44,7 @@ class RolePointsField(serializers.WritableField): return json.loads(obj) -class UserStorySerializer(WatchersValidator, serializers.ModelSerializer): +class UserStorySerializer(WatchersValidator, VotedResourceSerializerMixin, serializers.ModelSerializer): tags = TagsField(default=[], required=False) external_reference = PgArrayField(required=False) points = RolePointsField(source="role_points", required=False) diff --git a/taiga/projects/votes/migrations/0002_auto_20150805_1600.py b/taiga/projects/votes/migrations/0002_auto_20150805_1600.py new file mode 100644 index 00000000..c57f526c --- /dev/null +++ b/taiga/projects/votes/migrations/0002_auto_20150805_1600.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.utils.timezone import utc +from django.conf import settings +import datetime + + +class Migration(migrations.Migration): + + dependencies = [ + ('votes', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='vote', + name='created_date', + field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2015, 8, 5, 16, 0, 40, 158374, tzinfo=utc), verbose_name='created date'), + preserve_default=False, + ), + migrations.AlterField( + model_name='vote', + name='user', + field=models.ForeignKey(related_name='votes', to=settings.AUTH_USER_MODEL, verbose_name='user'), + preserve_default=True, + ), + migrations.AlterField( + model_name='votes', + name='count', + field=models.PositiveIntegerField(default=0, verbose_name='count'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/votes/mixins/__init__.py b/taiga/projects/votes/mixins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/projects/votes/mixins/serializers.py b/taiga/projects/votes/mixins/serializers.py new file mode 100644 index 00000000..96028eaf --- /dev/null +++ b/taiga/projects/votes/mixins/serializers.py @@ -0,0 +1,37 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 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 BaseVotedResourceSerializer(serializers.ModelSerializer): + def get_votes_counter(self, obj): + # The "votes_count" attribute is attached in the get_queryset of the viewset. + return getattr(obj, "votes_count", 0) or 0 + + def get_is_voted(self, obj): + # The "is_voted" attribute is attached in the get_queryset of the viewset. + return getattr(obj, "is_voted", False) or False + + +class StarredResourceSerializerMixin(BaseVotedResourceSerializer): + stars = serializers.SerializerMethodField("get_votes_counter") + is_starred = serializers.SerializerMethodField("get_is_voted") + + +class VotedResourceSerializerMixin(BaseVotedResourceSerializer): + votes = serializers.SerializerMethodField("get_votes_counter") + is_voted = serializers.SerializerMethodField("get_is_voted") diff --git a/taiga/projects/votes/mixins/viewsets.py b/taiga/projects/votes/mixins/viewsets.py new file mode 100644 index 00000000..83bf25bd --- /dev/null +++ b/taiga/projects/votes/mixins/viewsets.py @@ -0,0 +1,114 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 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.votes import serializers +from taiga.projects.votes import services +from taiga.projects.votes.utils import attach_votes_count_to_queryset, attach_is_vote_to_queryset + + +class BaseVotedResource: + # Note: Update get_queryset method: + # def get_queryset(self): + # qs = super().get_queryset() + # return self.attach_votes_attrs_to_queryset(qs) + + def attach_votes_attrs_to_queryset(self, queryset): + qs = attach_votes_count_to_queryset(queryset) + + if self.request.user.is_authenticated(): + qs = attach_is_vote_to_queryset(self.request.user, qs) + + return qs + + def _add_voter(self, permission, request, pk=None): + obj = self.get_object() + self.check_permissions(request, permission, obj) + + services.add_vote(obj, user=request.user) + return response.Ok() + + def _remove_vote(self, permission, request, pk=None): + obj = self.get_object() + self.check_permissions(request, permission, obj) + + services.remove_vote(obj, user=request.user) + return response.Ok() + + +class StarredResourceMixin(BaseVotedResource): + # Note: objects nedd 'star' and 'unstar' permissions. + + @detail_route(methods=["POST"]) + def star(self, request, pk=None): + return self._add_voter("star", request, pk) + + @detail_route(methods=["POST"]) + def unstar(self, request, pk=None): + return self._remove_vote("unstar", 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 VotersViewSetMixin: + # Is a ModelListViewSet with two required params: permission_classes and resource_model + serializer_class = serializers.VoterSerializer + list_serializer_class = serializers.VoterSerializer + 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_voters(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_voters(resource) diff --git a/taiga/projects/votes/models.py b/taiga/projects/votes/models.py index 5457c3ac..7fcf857a 100644 --- a/taiga/projects/votes/models.py +++ b/taiga/projects/votes/models.py @@ -16,16 +16,16 @@ # 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 _ -from django.contrib.contenttypes import generic class Votes(models.Model): content_type = models.ForeignKey("contenttypes.ContentType") object_id = models.PositiveIntegerField() content_object = generic.GenericForeignKey("content_type", "object_id") - count = models.PositiveIntegerField(default=0) + count = models.PositiveIntegerField(null=False, blank=False, default=0, verbose_name=_("count")) class Meta: verbose_name = _("Votes") @@ -44,10 +44,12 @@ class Votes(models.Model): class Vote(models.Model): content_type = models.ForeignKey("contenttypes.ContentType") - object_id = models.PositiveIntegerField(null=False) + 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="votes", verbose_name=_("votes")) + related_name="votes", verbose_name=_("user")) + created_date = models.DateTimeField(null=False, blank=False, auto_now_add=True, + verbose_name=_("created date")) class Meta: verbose_name = _("Vote") diff --git a/taiga/projects/votes/utils.py b/taiga/projects/votes/utils.py index 20e72b6d..bff72a6a 100644 --- a/taiga/projects/votes/utils.py +++ b/taiga/projects/votes/utils.py @@ -18,7 +18,7 @@ from django.apps import apps -def attach_votescount_to_queryset(queryset, as_field="votes_count"): +def attach_votes_count_to_queryset(queryset, as_field="votes_count"): """Attach votes count to each object of the queryset. Because of laziness of vote objects creation, this makes much simpler and more efficient to @@ -34,8 +34,40 @@ def attach_votescount_to_queryset(queryset, as_field="votes_count"): """ model = queryset.model type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model) - sql = ("SELECT coalesce(votes_votes.count, 0) FROM votes_votes " - "WHERE votes_votes.content_type_id = {type_id} AND votes_votes.object_id = {tbl}.id") + sql = ("""SELECT coalesce(votes_votes.count, 0) + FROM votes_votes + WHERE votes_votes.content_type_id = {type_id} + AND votes_votes.object_id = {tbl}.id""") sql = sql.format(type_id=type.id, tbl=model._meta.db_table) qs = queryset.extra(select={as_field: sql}) return qs + + +def attach_is_vote_to_queryset(user, queryset, as_field="is_voted"): + """Attach is_vote boolean to each object of the queryset. + + Because of laziness of vote objects creation, this makes much simpler and more efficient to + access to votes-object and check if the curren user vote 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 votes_vote + WHERE votes_vote.content_type_id = {type_id} + AND votes_vote.object_id = {tbl}.id + AND votes_vote.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/routers.py b/taiga/routers.py index 0f8bc675..ff7ceff0 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -1,4 +1,3 @@ - # Copyright (C) 2014 Andrey Antukh # Copyright (C) 2014 Jesús Espino # Copyright (C) 2014 David Barragán @@ -49,6 +48,7 @@ router.register(r"notify-policies", NotifyPolicyViewSet, base_name="notification # Projects & Selectors from taiga.projects.api import ProjectViewSet +from taiga.projects.api import ProjectFansViewSet from taiga.projects.api import MembershipViewSet from taiga.projects.api import InvitationViewSet from taiga.projects.api import UserStoryStatusViewSet @@ -61,6 +61,7 @@ from taiga.projects.api import SeverityViewSet from taiga.projects.api import ProjectTemplateViewSet router.register(r"projects", ProjectViewSet, base_name="projects") +router.register(r"projects/(?P\d+)/fans", ProjectFansViewSet, base_name="project-fans") router.register(r"project-templates", ProjectTemplateViewSet, base_name="project-templates") router.register(r"memberships", MembershipViewSet, base_name="memberships") router.register(r"invitations", InvitationViewSet, base_name="invitations") @@ -124,20 +125,26 @@ router.register(r"wiki/attachments", WikiAttachmentViewSet, base_name="wiki-atta # Project components from taiga.projects.milestones.api import MilestoneViewSet from taiga.projects.userstories.api import UserStoryViewSet +from taiga.projects.userstories.api import UserStoryVotersViewSet from taiga.projects.tasks.api import TaskViewSet +from taiga.projects.tasks.api import TaskVotersViewSet from taiga.projects.issues.api import IssueViewSet -from taiga.projects.issues.api import VotersViewSet +from taiga.projects.issues.api import IssueVotersViewSet from taiga.projects.wiki.api import WikiViewSet, WikiLinkViewSet router.register(r"milestones", MilestoneViewSet, base_name="milestones") router.register(r"userstories", UserStoryViewSet, base_name="userstories") +router.register(r"userstories/(?P\d+)/voters", UserStoryVotersViewSet, base_name="userstory-voters") router.register(r"tasks", TaskViewSet, base_name="tasks") +router.register(r"tasks/(?P\d+)/voters", TaskVotersViewSet, base_name="task-voters") router.register(r"issues", IssueViewSet, base_name="issues") -router.register(r"issues/(?P\d+)/voters", VotersViewSet, base_name="issue-voters") +router.register(r"issues/(?P\d+)/voters", IssueVotersViewSet, base_name="issue-voters") router.register(r"wiki", WikiViewSet, base_name="wiki") router.register(r"wiki-links", WikiLinkViewSet, base_name="wiki-links") + + # History & Components from taiga.projects.history.api import UserStoryHistory from taiga.projects.history.api import TaskHistory diff --git a/taiga/users/api.py b/taiga/users/api.py index 33ec137c..0cd8f50c 100644 --- a/taiga/users/api.py +++ b/taiga/users/api.py @@ -224,15 +224,6 @@ class UsersViewSet(ModelCrudViewSet): user_data = self.admin_serializer_class(request.user).data return response.Ok(user_data) - @detail_route(methods=["GET"]) - def starred(self, request, pk=None): - user = self.get_object() - self.check_permissions(request, 'starred', user) - - stars = votes_service.get_voted(user.pk, model=apps.get_model('projects', 'Project')) - stars_data = StarredSerializer(stars, many=True) - return response.Ok(stars_data.data) - #TODO: commit_on_success def partial_update(self, request, *args, **kwargs): """ diff --git a/tests/integration/resources_permissions/test_issues_resources.py b/tests/integration/resources_permissions/test_issues_resources.py index 43abdeac..f5a90e66 100644 --- a/tests/integration/resources_permissions/test_issues_resources.py +++ b/tests/integration/resources_permissions/test_issues_resources.py @@ -477,12 +477,10 @@ def test_issue_action_upvote(client, data): results = helper_test_http_method(client, 'post', public_url, "", users) assert results == [401, 200, 200, 200, 200] - results = helper_test_http_method(client, 'post', private_url1, "", users) assert results == [401, 200, 200, 200, 200] - results = helper_test_http_method(client, 'post', private_url2, "", users) - assert results == [401, 403, 403, 200, 200] + assert results == [404, 404, 404, 200, 200] def test_issue_action_downvote(client, data): @@ -500,18 +498,16 @@ def test_issue_action_downvote(client, data): results = helper_test_http_method(client, 'post', public_url, "", users) assert results == [401, 200, 200, 200, 200] - results = helper_test_http_method(client, 'post', private_url1, "", users) assert results == [401, 200, 200, 200, 200] - results = helper_test_http_method(client, 'post', private_url2, "", users) - assert results == [401, 403, 403, 200, 200] + assert results == [404, 404, 404, 200, 200] def test_issue_voters_list(client, data): - public_url = reverse('issue-voters-list', kwargs={"issue_id": data.public_issue.pk}) - private_url1 = reverse('issue-voters-list', kwargs={"issue_id": data.private_issue1.pk}) - private_url2 = reverse('issue-voters-list', kwargs={"issue_id": data.private_issue2.pk}) + public_url = reverse('issue-voters-list', kwargs={"resource_id": data.public_issue.pk}) + private_url1 = reverse('issue-voters-list', kwargs={"resource_id": data.private_issue1.pk}) + private_url2 = reverse('issue-voters-list', kwargs={"resource_id": data.private_issue2.pk}) users = [ None, @@ -523,21 +519,22 @@ def test_issue_voters_list(client, data): results = helper_test_http_method(client, 'get', public_url, None, users) assert results == [200, 200, 200, 200, 200] - results = helper_test_http_method(client, 'get', private_url1, None, users) assert results == [200, 200, 200, 200, 200] - results = helper_test_http_method(client, 'get', private_url2, None, users) assert results == [401, 403, 403, 200, 200] def test_issue_voters_retrieve(client, data): add_vote(data.public_issue, data.project_owner) - public_url = reverse('issue-voters-detail', kwargs={"issue_id": data.public_issue.pk, "pk": data.project_owner.pk}) + public_url = reverse('issue-voters-detail', kwargs={"resource_id": data.public_issue.pk, + "pk": data.project_owner.pk}) add_vote(data.private_issue1, data.project_owner) - private_url1 = reverse('issue-voters-detail', kwargs={"issue_id": data.private_issue1.pk, "pk": data.project_owner.pk}) + private_url1 = reverse('issue-voters-detail', kwargs={"resource_id": data.private_issue1.pk, + "pk": data.project_owner.pk}) add_vote(data.private_issue2, data.project_owner) - private_url2 = reverse('issue-voters-detail', kwargs={"issue_id": data.private_issue2.pk, "pk": data.project_owner.pk}) + private_url2 = reverse('issue-voters-detail', kwargs={"resource_id": data.private_issue2.pk, + "pk": data.project_owner.pk}) users = [ None, @@ -549,10 +546,8 @@ def test_issue_voters_retrieve(client, data): results = helper_test_http_method(client, 'get', public_url, None, users) assert results == [200, 200, 200, 200, 200] - results = helper_test_http_method(client, 'get', private_url1, None, users) assert results == [200, 200, 200, 200, 200] - results = helper_test_http_method(client, 'get', private_url2, None, users) assert results == [401, 403, 403, 200, 200] diff --git a/tests/integration/resources_permissions/test_projects_resource.py b/tests/integration/resources_permissions/test_projects_resource.py index 575e0d62..e485f497 100644 --- a/tests/integration/resources_permissions/test_projects_resource.py +++ b/tests/integration/resources_permissions/test_projects_resource.py @@ -198,6 +198,25 @@ def test_project_action_stats(client, data): assert results == [404, 404, 200, 200] +def test_project_action_issues_stats(client, data): + public_url = reverse('projects-issues-stats', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-issues-stats', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-issues-stats', kwargs={"pk": data.private_project2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [404, 404, 200, 200] + + def test_project_action_star(client, data): public_url = reverse('projects-star', kwargs={"pk": data.public_project.pk}) private1_url = reverse('projects-star', kwargs={"pk": data.private_project1.pk}) @@ -236,29 +255,10 @@ def test_project_action_unstar(client, data): assert results == [404, 404, 200, 200] -def test_project_action_issues_stats(client, data): - public_url = reverse('projects-issues-stats', kwargs={"pk": data.public_project.pk}) - private1_url = reverse('projects-issues-stats', kwargs={"pk": data.private_project1.pk}) - private2_url = reverse('projects-issues-stats', kwargs={"pk": data.private_project2.pk}) - - users = [ - None, - data.registered_user, - data.project_member_with_perms, - data.project_owner - ] - results = helper_test_http_method(client, 'get', public_url, None, users) - assert results == [200, 200, 200, 200] - results = helper_test_http_method(client, 'get', private1_url, None, users) - assert results == [200, 200, 200, 200] - results = helper_test_http_method(client, 'get', private2_url, None, users) - assert results == [404, 404, 200, 200] - - -def test_project_action_fans(client, data): - public_url = reverse('projects-fans', kwargs={"pk": data.public_project.pk}) - private1_url = reverse('projects-fans', kwargs={"pk": data.private_project1.pk}) - private2_url = reverse('projects-fans', kwargs={"pk": data.private_project2.pk}) +def test_project_fans_list(client, data): + public_url = reverse('project-fans-list', kwargs={"resource_id": data.public_project.pk}) + private1_url = reverse('project-fans-list', kwargs={"resource_id": data.private_project1.pk}) + private2_url = reverse('project-fans-list', kwargs={"resource_id": data.private_project2.pk}) users = [ None, @@ -273,13 +273,16 @@ def test_project_action_fans(client, data): results = helper_test_http_method_and_count(client, 'get', private1_url, None, users) assert results == [(200, 2), (200, 2), (200, 2), (200, 2), (200, 2)] results = helper_test_http_method_and_count(client, 'get', private2_url, None, users) - assert results == [(404, 1), (404, 1), (404, 1), (200, 2), (200, 2)] + assert results == [(401, 0), (403, 0), (403, 0), (200, 2), (200, 2)] -def test_user_action_starred(client, data): - url1 = reverse('users-starred', kwargs={"pk": data.project_member_without_perms.pk}) - url2 = reverse('users-starred', kwargs={"pk": data.project_member_with_perms.pk}) - url3 = reverse('users-starred', kwargs={"pk": data.project_owner.pk}) +def test_project_fans_retrieve(client, data): + public_url = reverse('project-fans-detail', kwargs={"resource_id": data.public_project.pk, + "pk": data.project_owner.pk}) + private1_url = reverse('project-fans-detail', kwargs={"resource_id": data.private_project1.pk, + "pk": data.project_owner.pk}) + private2_url = reverse('project-fans-detail', kwargs={"resource_id": data.private_project2.pk, + "pk": data.project_owner.pk}) users = [ None, @@ -289,12 +292,12 @@ def test_user_action_starred(client, data): data.project_owner ] - results = helper_test_http_method_and_count(client, 'get', url1, None, users) - assert results == [(200, 0), (200, 0), (200, 0), (200, 0), (200, 0)] - results = helper_test_http_method_and_count(client, 'get', url2, None, users) - assert results == [(200, 3), (200, 3), (200, 3), (200, 3), (200, 3)] - results = helper_test_http_method_and_count(client, 'get', url3, None, users) - assert results == [(200, 3), (200, 3), (200, 3), (200, 3), (200, 3)] + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] def test_project_action_create_template(client, data): diff --git a/tests/integration/resources_permissions/test_tasks_resources.py b/tests/integration/resources_permissions/test_tasks_resources.py index 9be43c09..b5c3c4f0 100644 --- a/tests/integration/resources_permissions/test_tasks_resources.py +++ b/tests/integration/resources_permissions/test_tasks_resources.py @@ -9,6 +9,7 @@ from taiga.projects.occ import OCCResourceMixin from tests import factories as f from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals +from taiga.projects.votes.services import add_vote from unittest import mock @@ -416,6 +417,96 @@ def test_task_action_bulk_create(client, data): assert results == [401, 403, 403, 200, 200] +def test_task_action_upvote(client, data): + public_url = reverse('tasks-upvote', kwargs={"pk": data.public_task.pk}) + private_url1 = reverse('tasks-upvote', kwargs={"pk": data.private_task1.pk}) + private_url2 = reverse('tasks-upvote', kwargs={"pk": data.private_task2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [404, 404, 404, 200, 200] + + +def test_task_action_downvote(client, data): + public_url = reverse('tasks-downvote', kwargs={"pk": data.public_task.pk}) + private_url1 = reverse('tasks-downvote', kwargs={"pk": data.private_task1.pk}) + private_url2 = reverse('tasks-downvote', kwargs={"pk": data.private_task2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [404, 404, 404, 200, 200] + + +def test_task_voters_list(client, data): + public_url = reverse('task-voters-list', kwargs={"resource_id": data.public_task.pk}) + private_url1 = reverse('task-voters-list', kwargs={"resource_id": data.private_task1.pk}) + private_url2 = reverse('task-voters-list', kwargs={"resource_id": data.private_task2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_task_voters_retrieve(client, data): + add_vote(data.public_task, data.project_owner) + public_url = reverse('task-voters-detail', kwargs={"resource_id": data.public_task.pk, + "pk": data.project_owner.pk}) + add_vote(data.private_task1, data.project_owner) + private_url1 = reverse('task-voters-detail', kwargs={"resource_id": data.private_task1.pk, + "pk": data.project_owner.pk}) + add_vote(data.private_task2, data.project_owner) + private_url2 = reverse('task-voters-detail', kwargs={"resource_id": data.private_task2.pk, + "pk": data.project_owner.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + def test_tasks_csv(client, data): url = reverse('tasks-csv') csv_public_uuid = data.public_project.tasks_csv_uuid diff --git a/tests/integration/resources_permissions/test_userstories_resources.py b/tests/integration/resources_permissions/test_userstories_resources.py index f41d84ee..c257244b 100644 --- a/tests/integration/resources_permissions/test_userstories_resources.py +++ b/tests/integration/resources_permissions/test_userstories_resources.py @@ -9,6 +9,7 @@ from taiga.projects.occ import OCCResourceMixin from tests import factories as f from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals +from taiga.projects.votes.services import add_vote from unittest import mock @@ -415,6 +416,95 @@ def test_user_story_action_bulk_update_order(client, data): results = helper_test_http_method(client, 'post', url, post_data, users) assert results == [401, 403, 403, 204, 204] +def test_user_story_action_upvote(client, data): + public_url = reverse('userstories-upvote', kwargs={"pk": data.public_user_story.pk}) + private_url1 = reverse('userstories-upvote', kwargs={"pk": data.private_user_story1.pk}) + private_url2 = reverse('userstories-upvote', kwargs={"pk": data.private_user_story2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [404, 404, 404, 200, 200] + + +def test_user_story_action_downvote(client, data): + public_url = reverse('userstories-downvote', kwargs={"pk": data.public_user_story.pk}) + private_url1 = reverse('userstories-downvote', kwargs={"pk": data.private_user_story1.pk}) + private_url2 = reverse('userstories-downvote', kwargs={"pk": data.private_user_story2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [404, 404, 404, 200, 200] + + +def test_user_story_voters_list(client, data): + public_url = reverse('userstory-voters-list', kwargs={"resource_id": data.public_user_story.pk}) + private_url1 = reverse('userstory-voters-list', kwargs={"resource_id": data.private_user_story1.pk}) + private_url2 = reverse('userstory-voters-list', kwargs={"resource_id": data.private_user_story2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_user_story_voters_retrieve(client, data): + add_vote(data.public_user_story, data.project_owner) + public_url = reverse('userstory-voters-detail', kwargs={"resource_id": data.public_user_story.pk, + "pk": data.project_owner.pk}) + add_vote(data.private_user_story1, data.project_owner) + private_url1 = reverse('userstory-voters-detail', kwargs={"resource_id": data.private_user_story1.pk, + "pk": data.project_owner.pk}) + add_vote(data.private_user_story2, data.project_owner) + private_url2 = reverse('userstory-voters-detail', kwargs={"resource_id": data.private_user_story2.pk, + "pk": data.project_owner.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + def test_user_stories_csv(client, data): url = reverse('userstories-csv') diff --git a/tests/integration/test_star_projects.py b/tests/integration/test_star_projects.py new file mode 100644 index 00000000..ad8d8e08 --- /dev/null +++ b/tests/integration/test_star_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_star_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-star", args=(project.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_unstar_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-unstar", 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.VoteFactory.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) + vote = f.VoteFactory.create(content_object=project, user=user) + url = reverse("project-fans-detail", args=(project.id, vote.user.id)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['id'] == vote.user.id + + +def test_get_project_stars(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.VotesFactory.create(content_object=project, count=5) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['stars'] == 5 + + +def test_get_project_is_starred(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_owner=True) + f.VotesFactory.create(content_object=project) + url_detail = reverse("projects-detail", args=(project.id,)) + url_star = reverse("projects-star", args=(project.id,)) + url_unstar = reverse("projects-unstar", args=(project.id,)) + + client.login(user) + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['stars'] == 0 + assert response.data['is_starred'] == False + + response = client.post(url_star) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['stars'] == 1 + assert response.data['is_starred'] == True + + response = client.post(url_unstar) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['stars'] == 0 + assert response.data['is_starred'] == False diff --git a/tests/integration/test_stars.py b/tests/integration/test_stars.py deleted file mode 100644 index eddb03a5..00000000 --- a/tests/integration/test_stars.py +++ /dev/null @@ -1,115 +0,0 @@ -# Copyright (C) 2014 Andrey Antukh -# Copyright (C) 2014 Jesús Espino -# Copyright (C) 2014 David Barragán -# Copyright (C) 2014 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_project_owner_star_project(client): - user = f.UserFactory.create() - project = f.ProjectFactory.create(owner=user) - f.MembershipFactory.create(project=project, is_owner=True, user=user) - url = reverse("projects-star", args=(project.id,)) - - client.login(user) - response = client.post(url) - - assert response.status_code == 200 - - -def test_project_owner_unstar_project(client): - user = f.UserFactory.create() - project = f.ProjectFactory.create(owner=user) - f.MembershipFactory.create(project=project, is_owner=True, user=user) - url = reverse("projects-unstar", args=(project.id,)) - - client.login(user) - response = client.post(url) - - assert response.status_code == 200 - - -def test_project_member_star_project(client): - user = f.UserFactory.create() - project = f.ProjectFactory.create() - role = f.RoleFactory.create(project=project, permissions=["view_project"]) - f.MembershipFactory.create(project=project, user=user, role=role) - url = reverse("projects-star", args=(project.id,)) - - client.login(user) - response = client.post(url) - - assert response.status_code == 200 - - -def test_project_member_unstar_project(client): - user = f.UserFactory.create() - project = f.ProjectFactory.create() - role = f.RoleFactory.create(project=project, permissions=["view_project"]) - f.MembershipFactory.create(project=project, user=user, role=role) - url = reverse("projects-unstar", 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.ProjectFactory.create(owner=user) - f.MembershipFactory.create(project=project, user=user, is_owner=True) - fan = f.VoteFactory.create(content_object=project) - url = reverse("projects-fans", args=(project.id,)) - - client.login(user) - response = client.get(url) - - assert response.status_code == 200 - assert response.data[0]['id'] == fan.user.id - - -def test_list_user_starred_projects(client): - user = f.UserFactory.create() - project = f.ProjectFactory() - url = reverse("users-starred", args=(user.id,)) - f.VoteFactory.create(user=user, content_object=project) - - client.login(user) - response = client.get(url) - - assert response.status_code == 200 - assert response.data[0]['id'] == project.id - - -def test_get_project_stars(client): - user = f.UserFactory.create() - project = f.ProjectFactory.create(owner=user) - f.MembershipFactory.create(project=project, user=user, is_owner=True) - url = reverse("projects-detail", args=(project.id,)) - f.VotesFactory.create(content_object=project, count=5) - f.VotesFactory.create(count=3) - - client.login(user) - response = client.get(url) - - assert response.status_code == 200 - assert response.data['stars'] == 5 diff --git a/tests/integration/test_vote_issues.py b/tests/integration/test_vote_issues.py index 691ce432..8faca67b 100644 --- a/tests/integration/test_vote_issues.py +++ b/tests/integration/test_vote_issues.py @@ -1,7 +1,7 @@ -# Copyright (C) 2014 Andrey Antukh -# Copyright (C) 2014 Jesús Espino -# Copyright (C) 2014 David Barragán -# Copyright (C) 2014 Anler Hernández +# 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 @@ -51,8 +51,8 @@ def test_list_issue_voters(client): user = f.UserFactory.create() issue = f.create_issue(owner=user) f.MembershipFactory.create(project=issue.project, user=user, is_owner=True) - url = reverse("issue-voters-list", args=(issue.id,)) f.VoteFactory.create(content_object=issue, user=user) + url = reverse("issue-voters-list", args=(issue.id,)) client.login(user) response = client.get(url) @@ -60,7 +60,6 @@ def test_list_issue_voters(client): assert response.status_code == 200 assert response.data[0]['id'] == user.id - def test_get_issue_voter(client): user = f.UserFactory.create() issue = f.create_issue(owner=user) @@ -74,7 +73,6 @@ def test_get_issue_voter(client): assert response.status_code == 200 assert response.data['id'] == vote.user.id - def test_get_issue_votes(client): user = f.UserFactory.create() issue = f.create_issue(owner=user) @@ -88,3 +86,36 @@ def test_get_issue_votes(client): assert response.status_code == 200 assert response.data['votes'] == 5 + + +def test_get_issue_is_voted(client): + user = f.UserFactory.create() + issue = f.create_issue(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_owner=True) + f.VotesFactory.create(content_object=issue) + url_detail = reverse("issues-detail", args=(issue.id,)) + url_upvote = reverse("issues-upvote", args=(issue.id,)) + url_downvote = reverse("issues-downvote", args=(issue.id,)) + + client.login(user) + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['votes'] == 0 + assert response.data['is_voted'] == False + + response = client.post(url_upvote) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['votes'] == 1 + assert response.data['is_voted'] == True + + response = client.post(url_downvote) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['votes'] == 0 + assert response.data['is_voted'] == False diff --git a/tests/integration/test_vote_tasks.py b/tests/integration/test_vote_tasks.py new file mode 100644 index 00000000..a5cdea56 --- /dev/null +++ b/tests/integration/test_vote_tasks.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_upvote_task(client): + user = f.UserFactory.create() + task = f.create_task(owner=user) + f.MembershipFactory.create(project=task.project, user=user, is_owner=True) + url = reverse("tasks-upvote", args=(task.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_downvote_task(client): + user = f.UserFactory.create() + task = f.create_task(owner=user) + f.MembershipFactory.create(project=task.project, user=user, is_owner=True) + url = reverse("tasks-downvote", args=(task.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_list_task_voters(client): + user = f.UserFactory.create() + task = f.create_task(owner=user) + f.MembershipFactory.create(project=task.project, user=user, is_owner=True) + f.VoteFactory.create(content_object=task, user=user) + url = reverse("task-voters-list", args=(task.id,)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data[0]['id'] == user.id + + +def test_get_task_voter(client): + user = f.UserFactory.create() + task = f.create_task(owner=user) + f.MembershipFactory.create(project=task.project, user=user, is_owner=True) + vote = f.VoteFactory.create(content_object=task, user=user) + url = reverse("task-voters-detail", args=(task.id, vote.user.id)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['id'] == vote.user.id + + +def test_get_task_votes(client): + user = f.UserFactory.create() + task = f.create_task(owner=user) + f.MembershipFactory.create(project=task.project, user=user, is_owner=True) + url = reverse("tasks-detail", args=(task.id,)) + + f.VotesFactory.create(content_object=task, count=5) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['votes'] == 5 + + +def test_get_task_is_voted(client): + user = f.UserFactory.create() + task = f.create_task(owner=user) + f.MembershipFactory.create(project=task.project, user=user, is_owner=True) + f.VotesFactory.create(content_object=task) + url_detail = reverse("tasks-detail", args=(task.id,)) + url_upvote = reverse("tasks-upvote", args=(task.id,)) + url_downvote = reverse("tasks-downvote", args=(task.id,)) + + client.login(user) + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['votes'] == 0 + assert response.data['is_voted'] == False + + response = client.post(url_upvote) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['votes'] == 1 + assert response.data['is_voted'] == True + + response = client.post(url_downvote) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['votes'] == 0 + assert response.data['is_voted'] == False diff --git a/tests/integration/test_vote_userstories.py b/tests/integration/test_vote_userstories.py new file mode 100644 index 00000000..ab863df3 --- /dev/null +++ b/tests/integration/test_vote_userstories.py @@ -0,0 +1,122 @@ +# 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_upvote_user_story(client): + user = f.UserFactory.create() + user_story = f.create_userstory(owner=user) + f.MembershipFactory.create(project=user_story.project, user=user, is_owner=True) + url = reverse("userstories-upvote", args=(user_story.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_downvote_user_story(client): + user = f.UserFactory.create() + user_story = f.create_userstory(owner=user) + f.MembershipFactory.create(project=user_story.project, user=user, is_owner=True) + url = reverse("userstories-downvote", args=(user_story.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_list_user_story_voters(client): + user = f.UserFactory.create() + user_story = f.create_userstory(owner=user) + f.MembershipFactory.create(project=user_story.project, user=user, is_owner=True) + f.VoteFactory.create(content_object=user_story, user=user) + url = reverse("userstory-voters-list", args=(user_story.id,)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data[0]['id'] == user.id + +def test_get_userstory_voter(client): + user = f.UserFactory.create() + user_story = f.create_userstory(owner=user) + f.MembershipFactory.create(project=user_story.project, user=user, is_owner=True) + vote = f.VoteFactory.create(content_object=user_story, user=user) + url = reverse("userstory-voters-detail", args=(user_story.id, vote.user.id)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['id'] == vote.user.id + + +def test_get_user_story_votes(client): + user = f.UserFactory.create() + user_story = f.create_userstory(owner=user) + f.MembershipFactory.create(project=user_story.project, user=user, is_owner=True) + url = reverse("userstories-detail", args=(user_story.id,)) + + f.VotesFactory.create(content_object=user_story, count=5) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['votes'] == 5 + + +def test_get_user_story_is_voted(client): + user = f.UserFactory.create() + user_story = f.create_userstory(owner=user) + f.MembershipFactory.create(project=user_story.project, user=user, is_owner=True) + f.VotesFactory.create(content_object=user_story) + url_detail = reverse("userstories-detail", args=(user_story.id,)) + url_upvote = reverse("userstories-upvote", args=(user_story.id,)) + url_downvote = reverse("userstories-downvote", args=(user_story.id,)) + + client.login(user) + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['votes'] == 0 + assert response.data['is_voted'] == False + + response = client.post(url_upvote) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['votes'] == 1 + assert response.data['is_voted'] == True + + response = client.post(url_downvote) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['votes'] == 0 + assert response.data['is_voted'] == False diff --git a/tests/utils.py b/tests/utils.py index 10f1fd9f..96320972 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -16,7 +16,6 @@ # along with this program. If not, see . from django.db.models import signals -from taiga.base.utils import json def signals_switch(): @@ -69,9 +68,9 @@ def helper_test_http_method(client, method, url, data, users, after_each_request def helper_test_http_method_and_count(client, method, url, data, users, after_each_request=None): responses = _helper_test_http_method_responses(client, method, url, data, users, after_each_request) - return list(map(lambda r: (r.status_code, len(json.loads(r.content.decode('utf-8')))), responses)) + return list(map(lambda r: (r.status_code, len(r.data) if isinstance(r.data, list) and 200 <= r.status_code < 300 else 0), responses)) def helper_test_http_method_and_keys(client, method, url, data, users, after_each_request=None): responses = _helper_test_http_method_responses(client, method, url, data, users, after_each_request) - return list(map(lambda r: (r.status_code, set(json.loads(r.content.decode('utf-8')).keys())), responses)) + return list(map(lambda r: (r.status_code, set(r.data.keys() if isinstance(r.data, dict) and 200 <= r.status_code < 300 else [])), responses)) From f3641f5cfbaaf08d97148ef52a4bf1fd6eef9283 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 18 Aug 2015 09:33:23 +0200 Subject: [PATCH 2/5] Refactoring and improving watchers --- CHANGELOG.md | 2 + taiga/base/fields.py | 9 ++ taiga/base/filters.py | 28 ++++ taiga/export_import/serializers.py | 63 +++++++-- taiga/export_import/service.py | 6 + taiga/permissions/service.py | 5 +- taiga/projects/api.py | 6 +- taiga/projects/apps.py | 8 +- taiga/projects/history/freeze_impl.py | 8 +- taiga/projects/issues/api.py | 10 +- .../migrations/0006_remove_issue_watchers.py | 29 ++++ taiga/projects/issues/permissions.py | 2 + taiga/projects/issues/serializers.py | 3 +- .../migrations/0024_auto_20150810_1247.py | 29 ++++ .../0025_remove_project_watchers.py | 28 ++++ taiga/projects/milestones/api.py | 4 +- .../0002_remove_milestone_watchers.py | 29 ++++ taiga/projects/milestones/serializers.py | 4 +- taiga/projects/models.py | 4 +- .../notifications/migrations/0004_watched.py | 35 +++++ taiga/projects/notifications/mixins.py | 125 ++++++++++++++++-- taiga/projects/notifications/models.py | 17 +++ taiga/projects/notifications/services.py | 53 +++++++- taiga/projects/notifications/utils.py | 63 +++++++++ taiga/projects/notifications/validators.py | 5 +- taiga/projects/permissions.py | 2 + taiga/projects/serializers.py | 5 +- taiga/projects/signals.py | 18 --- taiga/projects/tasks/api.py | 8 +- .../migrations/0008_remove_task_watchers.py | 29 ++++ taiga/projects/tasks/permissions.py | 2 + taiga/projects/tasks/serializers.py | 3 +- taiga/projects/userstories/api.py | 13 +- .../0010_remove_userstory_watchers.py | 29 ++++ taiga/projects/userstories/permissions.py | 3 +- taiga/projects/userstories/serializers.py | 3 +- taiga/projects/votes/serializers.py | 2 + .../0002_remove_wikipage_watchers.py | 30 +++++ taiga/projects/wiki/serializers.py | 7 +- taiga/timeline/signals.py | 2 +- .../migrations/0012_auto_20150812_1142.py | 21 +++ taiga/webhooks/serializers.py | 12 +- .../test_issues_resources.py | 42 ++++++ .../test_projects_resource.py | 38 ++++++ .../test_tasks_resources.py | 42 ++++++ .../test_userstories_resources.py | 42 ++++++ tests/integration/test_importer_api.py | 29 +++- tests/integration/test_notifications.py | 18 +-- tests/integration/test_projects.py | 2 +- tests/integration/test_timeline.py | 10 -- tests/integration/test_watch_issues.py | 47 +++++++ tests/integration/test_watch_milestones.py | 123 +++++++++++++++++ tests/integration/test_watch_projects.py | 47 +++++++ tests/integration/test_watch_tasks.py | 47 +++++++ tests/integration/test_watch_userstories.py | 47 +++++++ tests/integration/test_watch_wikipages.py | 123 +++++++++++++++++ 56 files changed, 1304 insertions(+), 117 deletions(-) create mode 100644 taiga/projects/issues/migrations/0006_remove_issue_watchers.py create mode 100644 taiga/projects/migrations/0024_auto_20150810_1247.py create mode 100644 taiga/projects/migrations/0025_remove_project_watchers.py create mode 100644 taiga/projects/milestones/migrations/0002_remove_milestone_watchers.py create mode 100644 taiga/projects/notifications/migrations/0004_watched.py create mode 100644 taiga/projects/notifications/utils.py create mode 100644 taiga/projects/tasks/migrations/0008_remove_task_watchers.py create mode 100644 taiga/projects/userstories/migrations/0010_remove_userstory_watchers.py create mode 100644 taiga/projects/wiki/migrations/0002_remove_wikipage_watchers.py create mode 100644 taiga/users/migrations/0012_auto_20150812_1142.py create mode 100644 tests/integration/test_watch_issues.py create mode 100644 tests/integration/test_watch_milestones.py create mode 100644 tests/integration/test_watch_projects.py create mode 100644 tests/integration/test_watch_tasks.py create mode 100644 tests/integration/test_watch_userstories.py create mode 100644 tests/integration/test_watch_wikipages.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bb640e86..05daf163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ - Now profile timelines only show content about the objects (US/Tasks/Issues/Wiki pages) you are involved. - US, tasks and Issues can be upvoted or downvoted and the voters list can be obtained. - Project can be starred or unstarred and the fans list can be obtained. +- Now users can watch public issues, tasks and user stories. +- Add endpoints to show the watchers list for issues, tasks and user stories. - i18n. - Add polish (pl) translation. - Add portuguese (Brazil) (pt_BR) translation. diff --git a/taiga/base/fields.py b/taiga/base/fields.py index a1a8c56c..74b27c10 100644 --- a/taiga/base/fields.py +++ b/taiga/base/fields.py @@ -110,3 +110,12 @@ class TagsColorsField(serializers.WritableField): def from_native(self, data): return list(data.items()) + + + +class WatchersField(serializers.WritableField): + def to_native(self, obj): + return obj + + def from_native(self, data): + return data diff --git a/taiga/base/filters.py b/taiga/base/filters.py index cbfdbdef..e0de384c 100644 --- a/taiga/base/filters.py +++ b/taiga/base/filters.py @@ -18,6 +18,7 @@ from functools import reduce import logging from django.apps import apps +from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.utils.translation import ugettext as _ @@ -451,6 +452,33 @@ class TagsFilter(FilterBackend): return super().filter_queryset(request, queryset, view) + +class WatchersFilter(FilterBackend): + filter_name = 'watchers' + + def __init__(self, filter_name=None): + if filter_name: + self.filter_name = filter_name + + def _get_watchers_queryparams(self, params): + watchers = params.get(self.filter_name, None) + if watchers: + return watchers.split(",") + + return None + + def filter_queryset(self, request, queryset, view): + query_watchers = self._get_watchers_queryparams(request.QUERY_PARAMS) + model = queryset.model + if query_watchers: + WatchedModel = apps.get_model("notifications", "Watched") + watched_type = ContentType.objects.get_for_model(queryset.model) + watched_ids = WatchedModel.objects.filter(content_type=watched_type, user__id__in=query_watchers).values_list("object_id", flat=True) + queryset = queryset.filter(id__in=watched_ids) + + return super().filter_queryset(request, queryset, view) + + ##################################################################### # Text search filters ##################################################################### diff --git a/taiga/export_import/serializers.py b/taiga/export_import/serializers.py index ec0ed783..98e9d2cf 100644 --- a/taiga/export_import/serializers.py +++ b/taiga/export_import/serializers.py @@ -19,6 +19,7 @@ import copy import os from collections import OrderedDict +from django.apps import apps from django.core.files.base import ContentFile from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError @@ -43,6 +44,7 @@ from taiga.projects.attachments import models as attachments_models from taiga.timeline import models as timeline_models from taiga.timeline import service as timeline_service from taiga.users import models as users_models +from taiga.projects.notifications import services as notifications_services from taiga.projects.votes import services as votes_service from taiga.projects.history import services as history_service @@ -223,6 +225,48 @@ class HistoryDiffField(JsonField): return data +class WatcheableObjectModelSerializer(serializers.ModelSerializer): + watchers = UserRelatedField(many=True, required=False) + + def __init__(self, *args, **kwargs): + self._watchers_field = self.base_fields.pop("watchers", None) + super(WatcheableObjectModelSerializer, self).__init__(*args, **kwargs) + + """ + watchers is not a field from the model so we need to do some magic to make it work like a normal field + It's supposed to be represented as an email list but internally it's treated like notifications.Watched instances + """ + + def restore_object(self, attrs, instance=None): + watcher_field = self.fields.pop("watchers", None) + instance = super(WatcheableObjectModelSerializer, self).restore_object(attrs, instance) + self._watchers = self.init_data.get("watchers", []) + return instance + + def save_watchers(self): + new_watcher_emails = set(self._watchers) + old_watcher_emails = set(notifications_services.get_watchers(self.object).values_list("email", flat=True)) + adding_watcher_emails = list(new_watcher_emails.difference(old_watcher_emails)) + removing_watcher_emails = list(old_watcher_emails.difference(new_watcher_emails)) + + User = apps.get_model("users", "User") + adding_users = User.objects.filter(email__in=adding_watcher_emails) + removing_users = User.objects.filter(email__in=removing_watcher_emails) + + for user in adding_users: + notifications_services.add_watcher(self.object, user) + + for user in removing_users: + notifications_services.remove_watcher(self.object, user) + + self.object.watchers = notifications_services.get_watchers(self.object) + + def to_native(self, obj): + ret = super(WatcheableObjectModelSerializer, self).to_native(obj) + ret["watchers"] = [user.email for user in notifications_services.get_watchers(obj)] + return ret + + class HistoryExportSerializer(serializers.ModelSerializer): user = HistoryUserField() diff = HistoryDiffField(required=False) @@ -243,7 +287,7 @@ class HistoryExportSerializerMixin(serializers.ModelSerializer): def get_history(self, obj): history_qs = history_service.get_history_queryset_by_model_instance(obj, types=(history_models.HistoryType.change, history_models.HistoryType.create,)) - + return HistoryExportSerializer(history_qs, many=True).data @@ -447,9 +491,8 @@ class RolePointsExportSerializer(serializers.ModelSerializer): exclude = ('id', 'user_story') -class MilestoneExportSerializer(serializers.ModelSerializer): +class MilestoneExportSerializer(WatcheableObjectModelSerializer): owner = UserRelatedField(required=False) - watchers = UserRelatedField(many=True, required=False) modified_date = serializers.DateTimeField(required=False) def __init__(self, *args, **kwargs): @@ -475,13 +518,12 @@ class MilestoneExportSerializer(serializers.ModelSerializer): class TaskExportSerializer(CustomAttributesValuesExportSerializerMixin, HistoryExportSerializerMixin, - AttachmentExportSerializerMixin, serializers.ModelSerializer): + AttachmentExportSerializerMixin, WatcheableObjectModelSerializer): owner = UserRelatedField(required=False) status = ProjectRelatedField(slug_field="name") user_story = ProjectRelatedField(slug_field="ref", required=False) milestone = ProjectRelatedField(slug_field="name", required=False) assigned_to = UserRelatedField(required=False) - watchers = UserRelatedField(many=True, required=False) modified_date = serializers.DateTimeField(required=False) class Meta: @@ -493,13 +535,12 @@ class TaskExportSerializer(CustomAttributesValuesExportSerializerMixin, HistoryE class UserStoryExportSerializer(CustomAttributesValuesExportSerializerMixin, HistoryExportSerializerMixin, - AttachmentExportSerializerMixin, serializers.ModelSerializer): + AttachmentExportSerializerMixin, WatcheableObjectModelSerializer): role_points = RolePointsExportSerializer(many=True, required=False) owner = UserRelatedField(required=False) assigned_to = UserRelatedField(required=False) status = ProjectRelatedField(slug_field="name") milestone = ProjectRelatedField(slug_field="name", required=False) - watchers = UserRelatedField(many=True, required=False) modified_date = serializers.DateTimeField(required=False) generated_from_issue = ProjectRelatedField(slug_field="ref", required=False) @@ -512,7 +553,7 @@ class UserStoryExportSerializer(CustomAttributesValuesExportSerializerMixin, His class IssueExportSerializer(CustomAttributesValuesExportSerializerMixin, HistoryExportSerializerMixin, - AttachmentExportSerializerMixin, serializers.ModelSerializer): + AttachmentExportSerializerMixin, WatcheableObjectModelSerializer): owner = UserRelatedField(required=False) status = ProjectRelatedField(slug_field="name") assigned_to = UserRelatedField(required=False) @@ -520,7 +561,6 @@ class IssueExportSerializer(CustomAttributesValuesExportSerializerMixin, History severity = ProjectRelatedField(slug_field="name") type = ProjectRelatedField(slug_field="name") milestone = ProjectRelatedField(slug_field="name", required=False) - watchers = UserRelatedField(many=True, required=False) votes = serializers.SerializerMethodField("get_votes") modified_date = serializers.DateTimeField(required=False) @@ -536,10 +576,9 @@ class IssueExportSerializer(CustomAttributesValuesExportSerializerMixin, History class WikiPageExportSerializer(HistoryExportSerializerMixin, AttachmentExportSerializerMixin, - serializers.ModelSerializer): + WatcheableObjectModelSerializer): owner = UserRelatedField(required=False) last_modifier = UserRelatedField(required=False) - watchers = UserRelatedField(many=True, required=False) modified_date = serializers.DateTimeField(required=False) class Meta: @@ -586,7 +625,7 @@ class TimelineExportSerializer(serializers.ModelSerializer): exclude = ('id', 'project', 'namespace', 'object_id') -class ProjectExportSerializer(serializers.ModelSerializer): +class ProjectExportSerializer(WatcheableObjectModelSerializer): owner = UserRelatedField(required=False) default_points = serializers.SlugRelatedField(slug_field="name", required=False) default_us_status = serializers.SlugRelatedField(slug_field="name", required=False) diff --git a/taiga/export_import/service.py b/taiga/export_import/service.py index 40578118..25b9be90 100644 --- a/taiga/export_import/service.py +++ b/taiga/export_import/service.py @@ -71,6 +71,7 @@ def store_project(data): if serialized.is_valid(): serialized.object._importing = True serialized.object.save() + serialized.save_watchers() return serialized add_errors("project", serialized.errors) return None @@ -217,6 +218,7 @@ def store_task(project, data): serialized.object._not_notify = True serialized.save() + serialized.save_watchers() if serialized.object.ref: sequence_name = refs.make_sequence_name(project) @@ -257,6 +259,7 @@ def store_milestone(project, milestone): serialized.object.project = project serialized.object._importing = True serialized.save() + serialized.save_watchers() for task_without_us in milestone.get("tasks_without_us", []): task_without_us["user_story"] = None @@ -320,6 +323,7 @@ def store_wiki_page(project, wiki_page): serialized.object._importing = True serialized.object._not_notify = True serialized.save() + serialized.save_watchers() for attachment in wiki_page.get("attachments", []): store_attachment(project, serialized.object, attachment) @@ -382,6 +386,7 @@ def store_user_story(project, data): serialized.object._not_notify = True serialized.save() + serialized.save_watchers() if serialized.object.ref: sequence_name = refs.make_sequence_name(project) @@ -442,6 +447,7 @@ def store_issue(project, data): serialized.object._not_notify = True serialized.save() + serialized.save_watchers() if serialized.object.ref: sequence_name = refs.make_sequence_name(project) diff --git a/taiga/permissions/service.py b/taiga/permissions/service.py index d9df5bd7..3a79b6ee 100644 --- a/taiga/permissions/service.py +++ b/taiga/permissions/service.py @@ -15,11 +15,12 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from taiga.projects.models import Membership, Project from .permissions import OWNERS_PERMISSIONS, MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_PERMISSIONS +from django.apps import apps def _get_user_project_membership(user, project): + Membership = apps.get_model("projects", "Membership") if user.is_anonymous(): return None @@ -30,7 +31,7 @@ def _get_user_project_membership(user, project): def _get_object_project(obj): project = None - + Project = apps.get_model("projects", "Project") if isinstance(obj, Project): project = obj elif obj and hasattr(obj, 'project'): diff --git a/taiga/projects/api.py b/taiga/projects/api.py index 9ff9be8c..6223adb0 100644 --- a/taiga/projects/api.py +++ b/taiga/projects/api.py @@ -31,6 +31,7 @@ from taiga.base.api.utils import get_object_or_404 from taiga.base.utils.slug import slugify_uniquely from taiga.projects.history.mixins import HistoryResourceMixin +from taiga.projects.notifications.mixins import WatchedResourceMixin from taiga.projects.mixins.ordering import BulkUpdateOrderMixin from taiga.projects.mixins.on_destroy import MoveOnDestroyMixin @@ -50,7 +51,7 @@ from .votes.mixins.viewsets import StarredResourceMixin, VotersViewSetMixin ## Project ###################################################### -class ProjectViewSet(StarredResourceMixin, HistoryResourceMixin, ModelCrudViewSet): +class ProjectViewSet(StarredResourceMixin, HistoryResourceMixin, WatchedResourceMixin, ModelCrudViewSet): queryset = models.Project.objects.all() serializer_class = serializers.ProjectDetailSerializer admin_serializer_class = serializers.ProjectDetailAdminSerializer @@ -62,7 +63,8 @@ class ProjectViewSet(StarredResourceMixin, HistoryResourceMixin, ModelCrudViewSe def get_queryset(self): qs = super().get_queryset() - return self.attach_votes_attrs_to_queryset(qs) + qs = self.attach_votes_attrs_to_queryset(qs) + return self.attach_watchers_attrs_to_queryset(qs) @list_route(methods=["POST"]) def bulk_update_order(self, request, **kwargs): diff --git a/taiga/projects/apps.py b/taiga/projects/apps.py index acdaa7da..06b42de8 100644 --- a/taiga/projects/apps.py +++ b/taiga/projects/apps.py @@ -27,12 +27,7 @@ def connect_memberships_signals(): sender=apps.get_model("projects", "Membership"), dispatch_uid='membership_pre_delete') - # On membership object is deleted, update watchers of all objects relation. - signals.post_delete.connect(handlers.update_watchers_on_membership_post_delete, - sender=apps.get_model("projects", "Membership"), - dispatch_uid='update_watchers_on_membership_post_delete') - - # On membership object is deleted, update watchers of all objects relation. + # On membership object is deleted, update notify policies of all objects relation. signals.post_save.connect(handlers.create_notify_policy, sender=apps.get_model("projects", "Membership"), dispatch_uid='create-notify-policy') @@ -67,7 +62,6 @@ def connect_task_status_signals(): def disconnect_memberships_signals(): signals.pre_delete.disconnect(sender=apps.get_model("projects", "Membership"), dispatch_uid='membership_pre_delete') - signals.post_delete.disconnect(sender=apps.get_model("projects", "Membership"), dispatch_uid='update_watchers_on_membership_post_delete') signals.post_save.disconnect(sender=apps.get_model("projects", "Membership"), dispatch_uid='create-notify-policy') diff --git a/taiga/projects/history/freeze_impl.py b/taiga/projects/history/freeze_impl.py index a591c666..d5ecc800 100644 --- a/taiga/projects/history/freeze_impl.py +++ b/taiga/projects/history/freeze_impl.py @@ -288,7 +288,7 @@ def userstory_freezer(us) -> dict: "milestone": us.milestone_id, "client_requirement": us.client_requirement, "team_requirement": us.team_requirement, - "watchers": [x.id for x in us.watchers.all()], + "watchers": [x.id for x in us.get_watchers()], "attachments": extract_attachments(us), "tags": us.tags, "points": points, @@ -315,7 +315,7 @@ def issue_freezer(issue) -> dict: "description": issue.description, "description_html": mdrender(issue.project, issue.description), "assigned_to": issue.assigned_to_id, - "watchers": [x.pk for x in issue.watchers.all()], + "watchers": [x.pk for x in issue.get_watchers()], "attachments": extract_attachments(issue), "tags": issue.tags, "is_blocked": issue.is_blocked, @@ -337,7 +337,7 @@ def task_freezer(task) -> dict: "description": task.description, "description_html": mdrender(task.project, task.description), "assigned_to": task.assigned_to_id, - "watchers": [x.pk for x in task.watchers.all()], + "watchers": [x.pk for x in task.get_watchers()], "attachments": extract_attachments(task), "taskboard_order": task.taskboard_order, "us_order": task.us_order, @@ -359,7 +359,7 @@ def wikipage_freezer(wiki) -> dict: "owner": wiki.owner_id, "content": wiki.content, "content_html": mdrender(wiki.project, wiki.content), - "watchers": [x.pk for x in wiki.watchers.all()], + "watchers": [x.pk for x in wiki.get_watchers()], "attachments": extract_attachments(wiki), } diff --git a/taiga/projects/issues/api.py b/taiga/projects/issues/api.py index 6fc78ce3..1007bdf1 100644 --- a/taiga/projects/issues/api.py +++ b/taiga/projects/issues/api.py @@ -53,6 +53,7 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W filters.SeveritiesFilter, filters.PrioritiesFilter, filters.TagsFilter, + filters.WatchersFilter, filters.QFilter, filters.OrderByFilterMixin) retrieve_exclude_filters = (filters.OwnersFilter, @@ -61,11 +62,11 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W filters.IssueTypesFilter, filters.SeveritiesFilter, filters.PrioritiesFilter, - filters.TagsFilter,) + filters.TagsFilter, + filters.WatchersFilter,) filter_fields = ("project", - "status__is_closed", - "watchers") + "status__is_closed") order_by_fields = ("type", "status", "severity", @@ -142,7 +143,8 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W def get_queryset(self): qs = super().get_queryset() qs = qs.prefetch_related("attachments") - return self.attach_votes_attrs_to_queryset(qs) + qs = self.attach_votes_attrs_to_queryset(qs) + return self.attach_watchers_attrs_to_queryset(qs) def pre_save(self, obj): if not obj.id: diff --git a/taiga/projects/issues/migrations/0006_remove_issue_watchers.py b/taiga/projects/issues/migrations/0006_remove_issue_watchers.py new file mode 100644 index 00000000..c41e387e --- /dev/null +++ b/taiga/projects/issues/migrations/0006_remove_issue_watchers.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.management import update_all_contenttypes + +def create_notifications(apps, schema_editor): + update_all_contenttypes() + migrations.RunSQL(sql=""" +INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id) +SELECT issue_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id +FROM issues_issue_watchers""".format(content_type_id=ContentType.objects.get(model='issue').id)) + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0004_watched'), + ('issues', '0005_auto_20150623_1923'), + ] + + operations = [ + migrations.RunPython(create_notifications), + migrations.RemoveField( + model_name='issue', + name='watchers', + ), + ] diff --git a/taiga/projects/issues/permissions.py b/taiga/projects/issues/permissions.py index 423866bc..82120e14 100644 --- a/taiga/projects/issues/permissions.py +++ b/taiga/projects/issues/permissions.py @@ -35,6 +35,8 @@ class IssuePermission(TaigaResourcePermission): delete_comment_perms= HasProjectPerm('modify_issue') upvote_perms = IsAuthenticated() & HasProjectPerm('view_issues') downvote_perms = IsAuthenticated() & HasProjectPerm('view_issues') + watch_perms = IsAuthenticated() & HasProjectPerm('view_issues') + unwatch_perms = IsAuthenticated() & HasProjectPerm('view_issues') class HasIssueIdUrlParam(PermissionComponent): diff --git a/taiga/projects/issues/serializers.py b/taiga/projects/issues/serializers.py index 76023cf0..64c83f17 100644 --- a/taiga/projects/issues/serializers.py +++ b/taiga/projects/issues/serializers.py @@ -23,6 +23,7 @@ from taiga.mdrender.service import render as mdrender from taiga.projects.validators import ProjectExistsValidator from taiga.projects.notifications.validators import WatchersValidator from taiga.projects.serializers import BasicIssueStatusSerializer +from taiga.projects.notifications.mixins import WatchedResourceModelSerializer from taiga.projects.votes.mixins.serializers import VotedResourceSerializerMixin from taiga.users.serializers import UserBasicInfoSerializer @@ -30,7 +31,7 @@ from taiga.users.serializers import UserBasicInfoSerializer from . import models -class IssueSerializer(WatchersValidator, VotedResourceSerializerMixin, serializers.ModelSerializer): +class IssueSerializer(WatchersValidator, VotedResourceSerializerMixin, WatchedResourceModelSerializer, serializers.ModelSerializer): tags = TagsField(required=False) external_reference = PgArrayField(required=False) is_closed = serializers.Field(source="is_closed") diff --git a/taiga/projects/migrations/0024_auto_20150810_1247.py b/taiga/projects/migrations/0024_auto_20150810_1247.py new file mode 100644 index 00000000..ebe758fc --- /dev/null +++ b/taiga/projects/migrations/0024_auto_20150810_1247.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import djorm_pgarray.fields +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('projects', '0023_auto_20150721_1511'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='watchers', + field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL, related_name='projects_project+', null=True, verbose_name='watchers'), + preserve_default=True, + ), + migrations.AlterField( + model_name='project', + name='public_permissions', + field=djorm_pgarray.fields.TextArrayField(default=[], dbtype='text', choices=[('view_project', 'View project'), ('star_project', 'Star project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('delete_us', 'Delete user story'), ('vote_us', 'Vote user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('delete_task', 'Delete task'), ('vote_task', 'Vote task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('delete_issue', 'Delete issue'), ('vote_issue', 'Vote issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')], verbose_name='user permissions'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/migrations/0025_remove_project_watchers.py b/taiga/projects/migrations/0025_remove_project_watchers.py new file mode 100644 index 00000000..5748edc9 --- /dev/null +++ b/taiga/projects/migrations/0025_remove_project_watchers.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.management import update_all_contenttypes + +def create_notifications(apps, schema_editor): + update_all_contenttypes() + migrations.RunSQL(sql=""" +INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id) +SELECT project_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id +FROM projects_project_watchers""".format(content_type_id=ContentType.objects.get(model='project').id)) + + +class Migration(migrations.Migration): + dependencies = [ + ('notifications', '0004_watched'), + ('projects', '0024_auto_20150810_1247'), + ] + + operations = [ + migrations.RunPython(create_notifications), + migrations.RemoveField( + model_name='project', + name='watchers', + ), + ] diff --git a/taiga/projects/milestones/api.py b/taiga/projects/milestones/api.py index 132f9bf2..93a09831 100644 --- a/taiga/projects/milestones/api.py +++ b/taiga/projects/milestones/api.py @@ -44,9 +44,7 @@ class MilestoneViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCrudView "user_stories__role_points__points", "user_stories__role_points__role", "user_stories__generated_from_issue", - "user_stories__project", - "watchers", - "user_stories__watchers") + "user_stories__project") qs = qs.select_related("project") qs = qs.order_by("-estimated_start") return qs diff --git a/taiga/projects/milestones/migrations/0002_remove_milestone_watchers.py b/taiga/projects/milestones/migrations/0002_remove_milestone_watchers.py new file mode 100644 index 00000000..897f47bf --- /dev/null +++ b/taiga/projects/milestones/migrations/0002_remove_milestone_watchers.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.management import update_all_contenttypes + +def create_notifications(apps, schema_editor): + update_all_contenttypes() + migrations.RunSQL(sql=""" +INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id) +SELECT milestone_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id +FROM milestones_milestone_watchers""".format(content_type_id=ContentType.objects.get(model='milestone').id)), + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0004_watched'), + ('milestones', '0001_initial'), + ] + + operations = [ + migrations.RunPython(create_notifications), + migrations.RemoveField( + model_name='milestone', + name='watchers', + ), + ] diff --git a/taiga/projects/milestones/serializers.py b/taiga/projects/milestones/serializers.py index 2ffd1d43..a2a483e5 100644 --- a/taiga/projects/milestones/serializers.py +++ b/taiga/projects/milestones/serializers.py @@ -19,12 +19,14 @@ from django.utils.translation import ugettext as _ from taiga.base.api import serializers from taiga.base.utils import json +from taiga.projects.notifications.mixins import WatchedResourceModelSerializer +from taiga.projects.notifications.validators import WatchersValidator from ..userstories.serializers import UserStorySerializer from . import models -class MilestoneSerializer(serializers.ModelSerializer): +class MilestoneSerializer(WatchersValidator, WatchedResourceModelSerializer, serializers.ModelSerializer): user_stories = UserStorySerializer(many=True, required=False, read_only=True) total_points = serializers.SerializerMethodField("get_total_points") closed_points = serializers.SerializerMethodField("get_closed_points") diff --git a/taiga/projects/models.py b/taiga/projects/models.py index 145edfb0..b2e2566f 100644 --- a/taiga/projects/models.py +++ b/taiga/projects/models.py @@ -40,6 +40,8 @@ from taiga.base.utils.slug import slugify_uniquely_for_queryset from . import choices +from . notifications.mixins import WatchedModelMixin + class Membership(models.Model): # This model stores all project memberships. Also @@ -118,7 +120,7 @@ class ProjectDefaults(models.Model): abstract = True -class Project(ProjectDefaults, TaggedMixin, models.Model): +class Project(ProjectDefaults, WatchedModelMixin, TaggedMixin, models.Model): name = models.CharField(max_length=250, null=False, blank=False, verbose_name=_("name")) slug = models.SlugField(max_length=250, unique=True, null=False, blank=True, diff --git a/taiga/projects/notifications/migrations/0004_watched.py b/taiga/projects/notifications/migrations/0004_watched.py new file mode 100644 index 00000000..53c85560 --- /dev/null +++ b/taiga/projects/notifications/migrations/0004_watched.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +def fill_watched_table(apps, schema_editor): + Watched = apps.get_model("notifications", "Watched") + print("test") + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0001_initial'), + ('notifications', '0003_auto_20141029_1143'), + ] + + operations = [ + migrations.CreateModel( + name='Watched', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), + ('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='watched', verbose_name='user', to=settings.AUTH_USER_MODEL)), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.RunPython(fill_watched_table), + ] diff --git a/taiga/projects/notifications/mixins.py b/taiga/projects/notifications/mixins.py index 362635a4..c5ffbb84 100644 --- a/taiga/projects/notifications/mixins.py +++ b/taiga/projects/notifications/mixins.py @@ -17,14 +17,22 @@ from functools import partial from operator import is_not -from django.conf import settings +from django.apps import apps +from django.contrib.contenttypes.models import ContentType from django.db import models from django.utils.translation import ugettext_lazy as _ +from taiga.base import response +from taiga.base.decorators import detail_route +from taiga.base.api import serializers +from taiga.base.fields import WatchersField from taiga.projects.notifications import services +from taiga.projects.notifications.utils import attach_watchers_to_queryset, attach_is_watched_to_queryset +from taiga.users.models import User +from . import models -class WatchedResourceMixin(object): +class WatchedResourceMixin: """ Rest Framework resource mixin for resources susceptible to be notifiable about their changes. @@ -36,6 +44,27 @@ class WatchedResourceMixin(object): _not_notify = False + def attach_watchers_attrs_to_queryset(self, queryset): + qs = attach_watchers_to_queryset(queryset) + if self.request.user.is_authenticated(): + qs = attach_is_watched_to_queryset(self.request.user, qs) + + return qs + + @detail_route(methods=["POST"]) + def watch(self, request, pk=None): + obj = self.get_object() + self.check_permissions(request, "watch", obj) + services.add_watcher(obj, request.user) + return response.Ok() + + @detail_route(methods=["POST"]) + def unwatch(self, request, pk=None): + obj = self.get_object() + self.check_permissions(request, "unwatch", obj) + services.remove_watcher(obj, request.user) + return response.Ok() + def send_notifications(self, obj, history=None): """ Shortcut method for resources with special save @@ -73,7 +102,7 @@ class WatchedResourceMixin(object): super().pre_delete(obj) -class WatchedModelMixin(models.Model): +class WatchedModelMixin(object): """ Generic model mixin that makes model compatible with notification system. @@ -82,11 +111,6 @@ class WatchedModelMixin(models.Model): this mixin if you want send notifications about your model class. """ - watchers = models.ManyToManyField(settings.AUTH_USER_MODEL, null=True, blank=True, - related_name="%(app_label)s_%(class)s+", - verbose_name=_("watchers")) - class Meta: - abstract = True def get_project(self) -> object: """ @@ -97,6 +121,7 @@ class WatchedModelMixin(models.Model): that should works in almost all cases. """ return self.project + t def get_watchers(self) -> frozenset: """ @@ -112,7 +137,13 @@ class WatchedModelMixin(models.Model): very inefficient way for obtain watchers but at this momment is the simplest way. """ - return frozenset(self.watchers.all()) + return frozenset(services.get_watchers(self)) + + def add_watcher(self, user): + services.add_watcher(self, user) + + def remove_watcher(self, user): + services.remove_watcher(self, user) def get_owner(self) -> object: """ @@ -140,3 +171,79 @@ class WatchedModelMixin(models.Model): self.get_owner(),) is_not_none = partial(is_not, None) return frozenset(filter(is_not_none, participants)) + + +class WatchedResourceModelSerializer(serializers.ModelSerializer): + is_watched = serializers.SerializerMethodField("get_is_watched") + watchers = WatchersField(required=False) + + def get_is_watched(self, obj): + # The "is_watched" attribute is attached in the get_queryset of the viewset. + return getattr(obj, "is_watched", False) or False + + def restore_object(self, attrs, instance=None): + #watchers is not a field from the model but can be attached in the get_queryset of the viewset. + #If that's the case we need to remove it before calling the super method + watcher_field = self.fields.pop("watchers", None) + instance = super(WatchedResourceModelSerializer, self).restore_object(attrs, instance) + if instance is not None and self.validate_watchers(attrs, "watchers"): + new_watcher_ids = set(attrs.get("watchers", [])) + old_watcher_ids = set(services.get_watchers(instance).values_list("id", flat=True)) + adding_watcher_ids = list(new_watcher_ids.difference(old_watcher_ids)) + removing_watcher_ids = list(old_watcher_ids.difference(new_watcher_ids)) + + User = apps.get_model("users", "User") + adding_users = User.objects.filter(id__in=adding_watcher_ids) + removing_users = User.objects.filter(id__in=removing_watcher_ids) + for user in adding_users: + services.add_watcher(instance, user) + + for user in removing_users: + services.remove_watcher(instance, user) + + instance.watchers = services.get_watchers(instance) + + return instance + + + def to_native(self, obj): + #watchers is wasn't attached via the get_queryset of the viewset we need to manually add it + if not hasattr(obj, "watchers"): + obj.watchers = services.get_watchers(obj) + + return super(WatchedResourceModelSerializer, self).to_native(obj) + + +class WatchersViewSetMixin: + # Is a ModelListViewSet with two required params: permission_classes and resource_model + serializer_class = WatcherSerializer + list_serializer_class = WatcherSerializer + 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_watchers(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_watchers(resource) diff --git a/taiga/projects/notifications/models.py b/taiga/projects/notifications/models.py index 29983f90..753b8878 100644 --- a/taiga/projects/notifications/models.py +++ b/taiga/projects/notifications/models.py @@ -14,6 +14,8 @@ # 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 _ from django.utils import timezone @@ -72,3 +74,18 @@ class HistoryChangeNotification(models.Model): class Meta: unique_together = ("key", "owner", "project", "history_type") + + +class Watched(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, blank=False, null=False, + related_name="watched", verbose_name=_("user")) + created_date = models.DateTimeField(auto_now_add=True, null=False, blank=False, + verbose_name=_("created date")) + + class Meta: + verbose_name = _("Watched") + verbose_name_plural = _("Watched") + unique_together = ("content_type", "object_id", "user") diff --git a/taiga/projects/notifications/services.py b/taiga/projects/notifications/services.py index 75f7e48b..be19a493 100644 --- a/taiga/projects/notifications/services.py +++ b/taiga/projects/notifications/services.py @@ -17,10 +17,10 @@ from functools import partial from django.apps import apps -from django.db import IntegrityError +from django.db.transaction import atomic +from django.db import IntegrityError, transaction from django.contrib.contenttypes.models import ContentType from django.utils import timezone -from django.db import transaction from django.conf import settings from django.utils.translation import ugettext as _ @@ -36,7 +36,7 @@ from taiga.projects.history.services import (make_key_from_model_object, from taiga.permissions.service import user_has_perm from taiga.users.models import User -from .models import HistoryChangeNotification +from .models import HistoryChangeNotification, Watched def notify_policy_exists(project, user) -> bool: @@ -121,11 +121,11 @@ def analize_object_for_watchers(obj:object, history:object): if data["mentions"]: for user in data["mentions"]: - obj.watchers.add(user) + obj.add_watcher(user) # Adding the person who edited the object to the watchers if history.comment and not history.owner.is_system: - obj.watchers.add(history.owner) + obj.add_watcher(history.owner) def _filter_by_permissions(obj, user): UserStory = apps.get_model("userstories", "UserStory") @@ -282,3 +282,46 @@ def send_sync_notifications(notification_id): def process_sync_notifications(): for notification in HistoryChangeNotification.objects.all(): send_sync_notifications(notification.pk) + + +def get_watchers(obj): + User = apps.get_model("users", "User") + Watched = apps.get_model("notifications", "Watched") + content_type = ContentType.objects.get_for_model(obj) + watching_user_ids = Watched.objects.filter(content_type=content_type, object_id=obj.id).values_list("user__id", flat=True) + return User.objects.filter(id__in=watching_user_ids) + + +def add_watcher(obj, user): + """Add a watcher to an object. + + If the user is already watching the object nothing happends, so this function can be considered + idempotent. + + :param obj: Any Django model instance. + :param user: User adding the watch. :class:`~taiga.users.models.User` instance. + """ + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj) + with atomic(): + watched, created = Watched.objects.get_or_create(content_type=obj_type, object_id=obj.id, user=user) + if not created: + return + return watched + + +def remove_watcher(obj, user): + """Remove an watching user from an object. + + If the user has not watched the object nothing happens so this function can be considered + idempotent. + + :param obj: Any Django model instance. + :param user: User removing the watch. :class:`~taiga.users.models.User` instance. + """ + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj) + with atomic(): + qs = Watched.objects.filter(content_type=obj_type, object_id=obj.id, user=user) + if not qs.exists(): + return + + qs.delete() diff --git a/taiga/projects/notifications/utils.py b/taiga/projects/notifications/utils.py new file mode 100644 index 00000000..14edd0b3 --- /dev/null +++ b/taiga/projects/notifications/utils.py @@ -0,0 +1,63 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# Copyright (C) 2014 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_watchers_to_queryset(queryset, as_field="watchers"): + """Attach watching user ids to each object of the queryset. + + :param queryset: A Django queryset object. + :param as_field: Attach the watchers 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 array(SELECT user_id + FROM notifications_watched + WHERE notifications_watched.content_type_id = {type_id} + AND notifications_watched.object_id = {tbl}.id)""") + sql = sql.format(type_id=type.id, tbl=model._meta.db_table) + qs = queryset.extra(select={as_field: sql}) + + return qs + + +def attach_is_watched_to_queryset(user, queryset, as_field="is_watched"): + """Attach is_watched boolean to each object of the queryset. + + :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 notifications_watched + WHERE notifications_watched.content_type_id = {type_id} + AND notifications_watched.object_id = {tbl}.id + AND notifications_watched.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/notifications/validators.py b/taiga/projects/notifications/validators.py index 38c5750b..b28e0712 100644 --- a/taiga/projects/notifications/validators.py +++ b/taiga/projects/notifications/validators.py @@ -21,7 +21,7 @@ from taiga.base.api import serializers class WatchersValidator: def validate_watchers(self, attrs, source): - users = attrs[source] + users = attrs.get(source, []) # Try obtain a valid project if self.object is None and "project" in attrs: @@ -39,7 +39,8 @@ class WatchersValidator: # Check if incoming watchers are contained # in project members list - result = set(users).difference(set(project.members.all())) + member_ids = project.members.values_list("id", flat=True) + result = set(users).difference(member_ids) if result: raise serializers.ValidationError(_("Watchers contains invalid users")) diff --git a/taiga/projects/permissions.py b/taiga/projects/permissions.py index 236bc182..9cabdc97 100644 --- a/taiga/projects/permissions.py +++ b/taiga/projects/permissions.py @@ -62,6 +62,8 @@ class ProjectPermission(TaigaResourcePermission): tags_colors_perms = HasProjectPerm('view_project') star_perms = IsAuthenticated() & HasProjectPerm('view_project') unstar_perms = IsAuthenticated() & HasProjectPerm('view_project') + watch_perms = IsAuthenticated() & HasProjectPerm('view_project') + unwatch_perms = IsAuthenticated() & HasProjectPerm('view_project') create_template_perms = IsSuperUser() leave_perms = CanLeaveProject() diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index a7e96834..9a13c6e7 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.py @@ -25,6 +25,8 @@ from taiga.base.fields import PgArrayField from taiga.base.fields import TagsField from taiga.base.fields import TagsColorsField +from taiga.projects.notifications.validators import WatchersValidator + from taiga.users.services import get_photo_or_gravatar_url from taiga.users.serializers import UserSerializer from taiga.users.serializers import UserBasicInfoSerializer @@ -40,6 +42,7 @@ from .validators import ProjectExistsValidator from .custom_attributes.serializers import UserStoryCustomAttributeSerializer from .custom_attributes.serializers import TaskCustomAttributeSerializer from .custom_attributes.serializers import IssueCustomAttributeSerializer +from .notifications.mixins import WatchedResourceModelSerializer from .votes.mixins.serializers import StarredResourceSerializerMixin ###################################################### @@ -305,7 +308,7 @@ class ProjectMemberSerializer(serializers.ModelSerializer): ## Projects ###################################################### -class ProjectSerializer(StarredResourceSerializerMixin, serializers.ModelSerializer): +class ProjectSerializer(WatchersValidator, StarredResourceSerializerMixin, WatchedResourceModelSerializer, serializers.ModelSerializer): tags = TagsField(default=[], required=False) anon_permissions = PgArrayField(required=False) public_permissions = PgArrayField(required=False) diff --git a/taiga/projects/signals.py b/taiga/projects/signals.py index f6cb0b1a..6cfb0ddf 100644 --- a/taiga/projects/signals.py +++ b/taiga/projects/signals.py @@ -45,24 +45,6 @@ def membership_post_delete(sender, instance, using, **kwargs): instance.project.update_role_points() -def update_watchers_on_membership_post_delete(sender, instance, using, **kwargs): - models = [apps.get_model("userstories", "UserStory"), - apps.get_model("tasks", "Task"), - apps.get_model("issues", "Issue")] - - # `user_id` is used beacuse in some momments - # instance.user can contain pointer to now - # removed object from a database. - for model in models: - #filter(project=instance.project) - filter = { - "user_id": instance.user_id, - "%s__project"%(model._meta.model_name): instance.project, - } - - model.watchers.through.objects.filter(**filter).delete() - - def create_notify_policy(sender, instance, using, **kwargs): if instance.user: create_notify_policy_if_not_exists(instance.project, instance.user) diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py index e2262632..2e2ad619 100644 --- a/taiga/projects/tasks/api.py +++ b/taiga/projects/tasks/api.py @@ -40,9 +40,10 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa ModelCrudViewSet): queryset = models.Task.objects.all() permission_classes = (permissions.TaskPermission,) - filter_backends = (filters.CanViewTasksFilterBackend,) + filter_backends = (filters.CanViewTasksFilterBackend, filters.WatchersFilter) + retrieve_exclude_filters = (filters.WatchersFilter,) filter_fields = ["user_story", "milestone", "project", "assigned_to", - "status__is_closed", "watchers"] + "status__is_closed"] def get_serializer_class(self, *args, **kwargs): if self.action in ["retrieve", "by_ref"]: @@ -86,7 +87,8 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa def get_queryset(self): qs = super().get_queryset() - return self.attach_votes_attrs_to_queryset(qs) + qs = self.attach_votes_attrs_to_queryset(qs) + return self.attach_watchers_attrs_to_queryset(qs) def pre_save(self, obj): if obj.user_story: diff --git a/taiga/projects/tasks/migrations/0008_remove_task_watchers.py b/taiga/projects/tasks/migrations/0008_remove_task_watchers.py new file mode 100644 index 00000000..813eaad9 --- /dev/null +++ b/taiga/projects/tasks/migrations/0008_remove_task_watchers.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.management import update_all_contenttypes + +def create_notifications(apps, schema_editor): + update_all_contenttypes() + migrations.RunSQL(sql=""" +INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id) +SELECT task_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id +FROM tasks_task_watchers""".format(content_type_id=ContentType.objects.get(model='task').id)), + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0004_watched'), + ('tasks', '0007_auto_20150629_1556'), + ] + + operations = [ + migrations.RunPython(create_notifications), + migrations.RemoveField( + model_name='task', + name='watchers', + ), + ] diff --git a/taiga/projects/tasks/permissions.py b/taiga/projects/tasks/permissions.py index af5fbd49..c9cbd667 100644 --- a/taiga/projects/tasks/permissions.py +++ b/taiga/projects/tasks/permissions.py @@ -33,6 +33,8 @@ class TaskPermission(TaigaResourcePermission): bulk_update_order_perms = HasProjectPerm('modify_task') upvote_perms = IsAuthenticated() & HasProjectPerm('view_tasks') downvote_perms = IsAuthenticated() & HasProjectPerm('view_tasks') + watch_perms = IsAuthenticated() & HasProjectPerm('view_tasks') + unwatch_perms = IsAuthenticated() & HasProjectPerm('view_tasks') class TaskVotersPermission(TaigaResourcePermission): diff --git a/taiga/projects/tasks/serializers.py b/taiga/projects/tasks/serializers.py index 3df25a77..221a188c 100644 --- a/taiga/projects/tasks/serializers.py +++ b/taiga/projects/tasks/serializers.py @@ -27,6 +27,7 @@ from taiga.projects.milestones.validators import SprintExistsValidator from taiga.projects.tasks.validators import TaskExistsValidator from taiga.projects.notifications.validators import WatchersValidator from taiga.projects.serializers import BasicTaskStatusSerializerSerializer +from taiga.projects.notifications.mixins import WatchedResourceModelSerializer from taiga.projects.votes.mixins.serializers import VotedResourceSerializerMixin from taiga.users.serializers import UserBasicInfoSerializer @@ -34,7 +35,7 @@ from taiga.users.serializers import UserBasicInfoSerializer from . import models -class TaskSerializer(WatchersValidator, VotedResourceSerializerMixin, serializers.ModelSerializer): +class TaskSerializer(WatchersValidator, VotedResourceSerializerMixin, WatchedResourceModelSerializer, serializers.ModelSerializer): tags = TagsField(required=False, default=[]) external_reference = PgArrayField(required=False) comment = serializers.SerializerMethodField("get_comment") diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index 8ae11d53..653fdc56 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -53,19 +53,20 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi filters.AssignedToFilter, filters.StatusesFilter, filters.TagsFilter, + filters.WatchersFilter, filters.QFilter, filters.OrderByFilterMixin) retrieve_exclude_filters = (filters.OwnersFilter, filters.AssignedToFilter, filters.StatusesFilter, - filters.TagsFilter) + filters.TagsFilter, + filters.WatchersFilter) filter_fields = ["project", "milestone", "milestone__isnull", "is_closed", "status__is_archived", - "status__is_closed", - "watchers"] + "status__is_closed"] order_by_fields = ["backlog_order", "sprint_order", "kanban_order"] @@ -113,10 +114,10 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi qs = super().get_queryset() qs = qs.prefetch_related("role_points", "role_points__points", - "role_points__role", - "watchers") + "role_points__role") qs = qs.select_related("milestone", "project") - return self.attach_votes_attrs_to_queryset(qs) + qs = self.attach_votes_attrs_to_queryset(qs) + return self.attach_watchers_attrs_to_queryset(qs) def pre_save(self, obj): # This is very ugly hack, but having diff --git a/taiga/projects/userstories/migrations/0010_remove_userstory_watchers.py b/taiga/projects/userstories/migrations/0010_remove_userstory_watchers.py new file mode 100644 index 00000000..d3e24f62 --- /dev/null +++ b/taiga/projects/userstories/migrations/0010_remove_userstory_watchers.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.management import update_all_contenttypes + +def create_notifications(apps, schema_editor): + update_all_contenttypes() + migrations.RunSQL(sql=""" +INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id) +SELECT userstory_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id +FROM userstories_userstory_watchers""".format(content_type_id=ContentType.objects.get(model='userstory').id)), + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0004_watched'), + ('userstories', '0009_remove_userstory_is_archived'), + ] + + operations = [ + migrations.RunPython(create_notifications), + migrations.RemoveField( + model_name='userstory', + name='watchers', + ), + ] diff --git a/taiga/projects/userstories/permissions.py b/taiga/projects/userstories/permissions.py index 00659141..0a1c7b8a 100644 --- a/taiga/projects/userstories/permissions.py +++ b/taiga/projects/userstories/permissions.py @@ -32,7 +32,8 @@ class UserStoryPermission(TaigaResourcePermission): bulk_update_order_perms = HasProjectPerm('modify_us') upvote_perms = IsAuthenticated() & HasProjectPerm('view_us') downvote_perms = IsAuthenticated() & HasProjectPerm('view_us') - + watch_perms = IsAuthenticated() & HasProjectPerm('view_us') + unwatch_perms = IsAuthenticated() & HasProjectPerm('view_us') class UserStoryVotersPermission(TaigaResourcePermission): enought_perms = IsProjectOwner() | IsSuperUser() diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py index 6c3a76cd..a461e57b 100644 --- a/taiga/projects/userstories/serializers.py +++ b/taiga/projects/userstories/serializers.py @@ -27,6 +27,7 @@ from taiga.projects.validators import UserStoryStatusExistsValidator from taiga.projects.userstories.validators import UserStoryExistsValidator from taiga.projects.notifications.validators import WatchersValidator from taiga.projects.serializers import BasicUserStoryStatusSerializer +from taiga.projects.notifications.mixins import WatchedResourceModelSerializer from taiga.projects.votes.mixins.serializers import VotedResourceSerializerMixin from taiga.users.serializers import UserBasicInfoSerializer @@ -44,7 +45,7 @@ class RolePointsField(serializers.WritableField): return json.loads(obj) -class UserStorySerializer(WatchersValidator, VotedResourceSerializerMixin, serializers.ModelSerializer): +class UserStorySerializer(WatchersValidator, VotedResourceSerializerMixin, WatchedResourceModelSerializer, serializers.ModelSerializer): tags = TagsField(default=[], required=False) external_reference = PgArrayField(required=False) points = RolePointsField(source="role_points", required=False) diff --git a/taiga/projects/votes/serializers.py b/taiga/projects/votes/serializers.py index c72ae91e..b6ab72a8 100644 --- a/taiga/projects/votes/serializers.py +++ b/taiga/projects/votes/serializers.py @@ -16,8 +16,10 @@ # 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 VoterSerializer(serializers.ModelSerializer): diff --git a/taiga/projects/wiki/migrations/0002_remove_wikipage_watchers.py b/taiga/projects/wiki/migrations/0002_remove_wikipage_watchers.py new file mode 100644 index 00000000..d0c1c832 --- /dev/null +++ b/taiga/projects/wiki/migrations/0002_remove_wikipage_watchers.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.apps import apps +from django.db import models, migrations +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.management import update_all_contenttypes + +def create_notifications(apps, schema_editor): + update_all_contenttypes() + migrations.RunSQL(sql=""" +INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id) +SELECT wikipage_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id +FROM wiki_wikipage_watchers""".format(content_type_id=ContentType.objects.get(model='wikipage').id)), + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0004_watched'), + ('wiki', '0001_initial'), + ] + + operations = [ + migrations.RunPython(create_notifications), + migrations.RemoveField( + model_name='wikipage', + name='watchers', + ), + ] diff --git a/taiga/projects/wiki/serializers.py b/taiga/projects/wiki/serializers.py index 45d3c99b..a528fdd8 100644 --- a/taiga/projects/wiki/serializers.py +++ b/taiga/projects/wiki/serializers.py @@ -15,6 +15,10 @@ # along with this program. If not, see . from taiga.base.api import serializers +from taiga.projects.history import services as history_service +from taiga.projects.notifications.mixins import WatchedResourceModelSerializer +from taiga.projects.notifications.validators import WatchersValidator +from taiga.mdrender.service import render as mdrender from . import models @@ -23,7 +27,7 @@ from taiga.projects.history import services as history_service from taiga.mdrender.service import render as mdrender -class WikiPageSerializer(serializers.ModelSerializer): +class WikiPageSerializer(WatchersValidator, WatchedResourceModelSerializer, serializers.ModelSerializer): html = serializers.SerializerMethodField("get_html") editions = serializers.SerializerMethodField("get_editions") @@ -39,6 +43,5 @@ class WikiPageSerializer(serializers.ModelSerializer): class WikiLinkSerializer(serializers.ModelSerializer): - class Meta: model = models.WikiLink diff --git a/taiga/timeline/signals.py b/taiga/timeline/signals.py index 7769817d..d56db4ac 100644 --- a/taiga/timeline/signals.py +++ b/taiga/timeline/signals.py @@ -62,7 +62,7 @@ def _push_to_timelines(project, user, obj, event_type, created_datetime, extra_d ## - Watchers watchers = getattr(obj, "watchers", None) if watchers: - related_people |= obj.watchers.all() + related_people |= obj.get_watchers() ## - Exclude inactive and system users and remove duplicate related_people = related_people.exclude(is_active=False) diff --git a/taiga/users/migrations/0012_auto_20150812_1142.py b/taiga/users/migrations/0012_auto_20150812_1142.py new file mode 100644 index 00000000..fff8c17b --- /dev/null +++ b/taiga/users/migrations/0012_auto_20150812_1142.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import djorm_pgarray.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0011_user_theme'), + ] + + operations = [ + migrations.AlterField( + model_name='role', + name='permissions', + field=djorm_pgarray.fields.TextArrayField(choices=[('view_project', 'View project'), ('star_project', 'Star project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('delete_us', 'Delete user story'), ('vote_us', 'Vote user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('delete_task', 'Delete task'), ('vote_task', 'Vote task'), ('view_issues', 'View issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('delete_issue', 'Delete issue'), ('vote_issue', 'Vote issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')], verbose_name='permissions', default=[], dbtype='text'), + preserve_default=True, + ), + ] diff --git a/taiga/webhooks/serializers.py b/taiga/webhooks/serializers.py index e802c1b3..47d0a145 100644 --- a/taiga/webhooks/serializers.py +++ b/taiga/webhooks/serializers.py @@ -23,8 +23,9 @@ from taiga.projects.userstories import models as us_models from taiga.projects.tasks import models as task_models from taiga.projects.issues import models as issue_models from taiga.projects.milestones import models as milestone_models -from taiga.projects.history import models as history_models from taiga.projects.wiki import models as wiki_models +from taiga.projects.history import models as history_models +from taiga.projects.notifications.mixins import WatchedResourceModelSerializer from .models import Webhook, WebhookLog @@ -103,7 +104,8 @@ class PointSerializer(serializers.Serializer): return obj.value -class UserStorySerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.ModelSerializer): +class UserStorySerializer(CustomAttributesValuesWebhookSerializerMixin, WatchedResourceModelSerializer, + serializers.ModelSerializer): tags = TagsField(default=[], required=False) external_reference = PgArrayField(required=False) owner = UserSerializer() @@ -119,7 +121,8 @@ class UserStorySerializer(CustomAttributesValuesWebhookSerializerMixin, serializ return project.userstorycustomattributes.all() -class TaskSerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.ModelSerializer): +class TaskSerializer(CustomAttributesValuesWebhookSerializerMixin, WatchedResourceModelSerializer, + serializers.ModelSerializer): tags = TagsField(default=[], required=False) owner = UserSerializer() assigned_to = UserSerializer() @@ -132,7 +135,8 @@ class TaskSerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.M return project.taskcustomattributes.all() -class IssueSerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.ModelSerializer): +class IssueSerializer(CustomAttributesValuesWebhookSerializerMixin, WatchedResourceModelSerializer, + serializers.ModelSerializer): tags = TagsField(default=[], required=False) owner = UserSerializer() assigned_to = UserSerializer() diff --git a/tests/integration/resources_permissions/test_issues_resources.py b/tests/integration/resources_permissions/test_issues_resources.py index f5a90e66..d9950e00 100644 --- a/tests/integration/resources_permissions/test_issues_resources.py +++ b/tests/integration/resources_permissions/test_issues_resources.py @@ -574,3 +574,45 @@ def test_issues_csv(client, data): results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private2_uuid), None, users) assert results == [200, 200, 200, 200, 200] + + +def test_issue_action_watch(client, data): + public_url = reverse('issues-watch', kwargs={"pk": data.public_issue.pk}) + private_url1 = reverse('issues-watch', kwargs={"pk": data.private_issue1.pk}) + private_url2 = reverse('issues-watch', kwargs={"pk": data.private_issue2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [404, 404, 404, 200, 200] + + +def test_issue_action_unwatch(client, data): + public_url = reverse('issues-unwatch', kwargs={"pk": data.public_issue.pk}) + private_url1 = reverse('issues-unwatch', kwargs={"pk": data.private_issue1.pk}) + private_url2 = reverse('issues-unwatch', kwargs={"pk": data.private_issue2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [404, 404, 404, 200, 200] diff --git a/tests/integration/resources_permissions/test_projects_resource.py b/tests/integration/resources_permissions/test_projects_resource.py index e485f497..888b1ef4 100644 --- a/tests/integration/resources_permissions/test_projects_resource.py +++ b/tests/integration/resources_permissions/test_projects_resource.py @@ -416,3 +416,41 @@ def test_regenerate_issues_csv_uuid(client, data): results = helper_test_http_method(client, 'post', private2_url, None, users) assert results == [404, 404, 403, 200] + + +def test_project_action_watch(client, data): + public_url = reverse('projects-watch', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-watch', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-watch', kwargs={"pk": data.private_project2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'post', public_url, None, users) + assert results == [401, 200, 200, 200] + results = helper_test_http_method(client, 'post', private1_url, None, users) + assert results == [401, 200, 200, 200] + results = helper_test_http_method(client, 'post', private2_url, None, users) + assert results == [404, 404, 200, 200] + + +def test_project_action_unwatch(client, data): + public_url = reverse('projects-unwatch', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-unwatch', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-unwatch', kwargs={"pk": data.private_project2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'post', public_url, None, users) + assert results == [401, 200, 200, 200] + results = helper_test_http_method(client, 'post', private1_url, None, users) + assert results == [401, 200, 200, 200] + results = helper_test_http_method(client, 'post', private2_url, None, users) + assert results == [404, 404, 200, 200] diff --git a/tests/integration/resources_permissions/test_tasks_resources.py b/tests/integration/resources_permissions/test_tasks_resources.py index b5c3c4f0..bd4d0e09 100644 --- a/tests/integration/resources_permissions/test_tasks_resources.py +++ b/tests/integration/resources_permissions/test_tasks_resources.py @@ -529,3 +529,45 @@ def test_tasks_csv(client, data): results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private2_uuid), None, users) assert results == [200, 200, 200, 200, 200] + + +def test_task_action_watch(client, data): + public_url = reverse('tasks-watch', kwargs={"pk": data.public_task.pk}) + private_url1 = reverse('tasks-watch', kwargs={"pk": data.private_task1.pk}) + private_url2 = reverse('tasks-watch', kwargs={"pk": data.private_task2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [404, 404, 404, 200, 200] + + +def test_task_action_unwatch(client, data): + public_url = reverse('tasks-unwatch', kwargs={"pk": data.public_task.pk}) + private_url1 = reverse('tasks-unwatch', kwargs={"pk": data.private_task1.pk}) + private_url2 = reverse('tasks-unwatch', kwargs={"pk": data.private_task2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [404, 404, 404, 200, 200] diff --git a/tests/integration/resources_permissions/test_userstories_resources.py b/tests/integration/resources_permissions/test_userstories_resources.py index c257244b..219c4e2a 100644 --- a/tests/integration/resources_permissions/test_userstories_resources.py +++ b/tests/integration/resources_permissions/test_userstories_resources.py @@ -528,3 +528,45 @@ def test_user_stories_csv(client, data): results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private2_uuid), None, users) assert results == [200, 200, 200, 200, 200] + + +def test_user_story_action_watch(client, data): + public_url = reverse('userstories-watch', kwargs={"pk": data.public_user_story.pk}) + private_url1 = reverse('userstories-watch', kwargs={"pk": data.private_user_story1.pk}) + private_url2 = reverse('userstories-watch', kwargs={"pk": data.private_user_story2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [404, 404, 404, 200, 200] + + +def test_user_story_action_unwatch(client, data): + public_url = reverse('userstories-unwatch', kwargs={"pk": data.public_user_story.pk}) + private_url1 = reverse('userstories-unwatch', kwargs={"pk": data.private_user_story1.pk}) + private_url2 = reverse('userstories-unwatch', kwargs={"pk": data.private_user_story2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [404, 404, 404, 200, 200] diff --git a/tests/integration/test_importer_api.py b/tests/integration/test_importer_api.py index 96143112..073d7585 100644 --- a/tests/integration/test_importer_api.py +++ b/tests/integration/test_importer_api.py @@ -47,13 +47,15 @@ def test_invalid_project_import(client): def test_valid_project_import_without_extra_data(client): user = f.UserFactory.create() + user_watching = f.UserFactory.create(email="testing@taiga.io") client.login(user) url = reverse("importer-list") data = { "name": "Imported project", "description": "Imported project", - "roles": [{"name": "Role"}] + "roles": [{"name": "Role"}], + "watchers": ["testing@taiga.io"] } response = client.post(url, json.dumps(data), content_type="application/json") @@ -66,6 +68,7 @@ def test_valid_project_import_without_extra_data(client): ] assert all(map(lambda x: len(response_data[x]) == 0, must_empty_children)) assert response_data["owner"] == user.email + assert response_data["watchers"] == [user_watching.email] def test_valid_project_import_with_not_existing_memberships(client): @@ -383,6 +386,7 @@ def test_valid_issue_import_with_custom_attributes_values(client): def test_valid_issue_import_with_extra_data(client): user = f.UserFactory.create() + user_watching = f.UserFactory.create(email="testing@taiga.io") project = f.ProjectFactory.create(owner=user) f.MembershipFactory(project=project, user=user, is_owner=True) project.default_issue_type = f.IssueTypeFactory.create(project=project) @@ -403,7 +407,8 @@ def test_valid_issue_import_with_extra_data(client): "name": "imported attachment", "data": base64.b64encode(b"TEST").decode("utf-8") } - }] + }], + "watchers": ["testing@taiga.io"] } response = client.post(url, json.dumps(data), content_type="application/json") @@ -413,6 +418,7 @@ def test_valid_issue_import_with_extra_data(client): assert response_data["owner"] == user.email assert response_data["ref"] is not None assert response_data["finished_date"] == "2014-10-24T00:00:00+0000" + assert response_data["watchers"] == [user_watching.email] def test_invalid_issue_import_with_extra_data(client): @@ -535,6 +541,7 @@ def test_valid_us_import_without_extra_data(client): def test_valid_us_import_with_extra_data(client): user = f.UserFactory.create() + user_watching = f.UserFactory.create(email="testing@taiga.io") project = f.ProjectFactory.create(owner=user) f.MembershipFactory(project=project, user=user, is_owner=True) project.default_us_status = f.UserStoryStatusFactory.create(project=project) @@ -551,7 +558,8 @@ def test_valid_us_import_with_extra_data(client): "name": "imported attachment", "data": base64.b64encode(b"TEST").decode("utf-8") } - }] + }], + "watchers": ["testing@taiga.io"] } response = client.post(url, json.dumps(data), content_type="application/json") @@ -560,6 +568,7 @@ def test_valid_us_import_with_extra_data(client): assert len(response_data["attachments"]) == 1 assert response_data["owner"] == user.email assert response_data["ref"] is not None + assert response_data["watchers"] == [user_watching.email] def test_invalid_us_import_with_extra_data(client): @@ -664,6 +673,7 @@ def test_valid_task_import_with_custom_attributes_values(client): def test_valid_task_import_with_extra_data(client): user = f.UserFactory.create() + user_watching = f.UserFactory.create(email="testing@taiga.io") project = f.ProjectFactory.create(owner=user) f.MembershipFactory(project=project, user=user, is_owner=True) project.default_task_status = f.TaskStatusFactory.create(project=project) @@ -680,7 +690,8 @@ def test_valid_task_import_with_extra_data(client): "name": "imported attachment", "data": base64.b64encode(b"TEST").decode("utf-8") } - }] + }], + "watchers": ["testing@taiga.io"] } response = client.post(url, json.dumps(data), content_type="application/json") @@ -689,6 +700,7 @@ def test_valid_task_import_with_extra_data(client): assert len(response_data["attachments"]) == 1 assert response_data["owner"] == user.email assert response_data["ref"] is not None + assert response_data["watchers"] == [user_watching.email] def test_invalid_task_import_with_extra_data(client): @@ -787,6 +799,7 @@ def test_valid_wiki_page_import_without_extra_data(client): def test_valid_wiki_page_import_with_extra_data(client): user = f.UserFactory.create() + user_watching = f.UserFactory.create(email="testing@taiga.io") project = f.ProjectFactory.create(owner=user) f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) @@ -801,7 +814,8 @@ def test_valid_wiki_page_import_with_extra_data(client): "name": "imported attachment", "data": base64.b64encode(b"TEST").decode("utf-8") } - }] + }], + "watchers": ["testing@taiga.io"] } response = client.post(url, json.dumps(data), content_type="application/json") @@ -809,6 +823,7 @@ def test_valid_wiki_page_import_with_extra_data(client): response_data = response.data assert len(response_data["attachments"]) == 1 assert response_data["owner"] == user.email + assert response_data["watchers"] == [user_watching.email] def test_invalid_wiki_page_import_with_extra_data(client): @@ -877,6 +892,7 @@ def test_invalid_milestone_import(client): def test_valid_milestone_import(client): user = f.UserFactory.create() + user_watching = f.UserFactory.create(email="testing@taiga.io") project = f.ProjectFactory.create(owner=user) f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) @@ -886,11 +902,12 @@ def test_valid_milestone_import(client): "name": "Imported milestone", "estimated_start": "2014-10-10", "estimated_finish": "2014-10-20", + "watchers": ["testing@taiga.io"] } response = client.post(url, json.dumps(data), content_type="application/json") assert response.status_code == 201 - response.data + assert response.data["watchers"] == [user_watching.email] diff --git a/tests/integration/test_notifications.py b/tests/integration/test_notifications.py index a5a9847c..ab0fdb42 100644 --- a/tests/integration/test_notifications.py +++ b/tests/integration/test_notifications.py @@ -97,7 +97,7 @@ def test_analize_object_for_watchers(): history.comment = "" services.analize_object_for_watchers(issue, history) - assert issue.watchers.add.call_count == 2 + assert issue.add_watcher.call_count == 2 def test_analize_object_for_watchers_adding_owner_non_empty_comment(): @@ -112,7 +112,7 @@ def test_analize_object_for_watchers_adding_owner_non_empty_comment(): history.owner = user1 services.analize_object_for_watchers(issue, history) - assert issue.watchers.add.call_count == 1 + assert issue.add_watcher.call_count == 1 def test_analize_object_for_watchers_no_adding_owner_empty_comment(): @@ -127,7 +127,7 @@ def test_analize_object_for_watchers_no_adding_owner_empty_comment(): history.owner = user1 services.analize_object_for_watchers(issue, history) - assert issue.watchers.add.call_count == 0 + assert issue.add_watcher.call_count == 0 def test_users_to_notify(): @@ -180,7 +180,7 @@ def test_users_to_notify(): assert users == {member1.user, issue.get_owner()} # Test with watchers - issue.watchers.add(member3.user) + issue.add_watcher(member3.user) users = services.get_users_to_notify(issue) assert len(users) == 3 assert users == {member1.user, member3.user, issue.get_owner()} @@ -189,24 +189,24 @@ def test_users_to_notify(): policy2.notify_level = NotifyLevel.ignore policy2.save() - issue.watchers.add(member3.user) + issue.add_watcher(member3.user) users = services.get_users_to_notify(issue) assert len(users) == 2 assert users == {member1.user, issue.get_owner()} # Test with watchers without permissions - issue.watchers.add(member5.user) + issue.add_watcher(member5.user) users = services.get_users_to_notify(issue) assert len(users) == 2 assert users == {member1.user, issue.get_owner()} # Test with inactive user - issue.watchers.add(inactive_member1.user) + issue.add_watcher(inactive_member1.user) assert len(users) == 2 assert users == {member1.user, issue.get_owner()} # Test with system user - issue.watchers.add(system_member1.user) + issue.add_watcher(system_member1.user) assert len(users) == 2 assert users == {member1.user, issue.get_owner()} @@ -344,7 +344,7 @@ def test_watchers_assignation_for_issue(client): issue = f.create_issue(project=project1, owner=user1) data = {"version": issue.version, - "watchers": [user1.pk]} + "watchersa": [user1.pk]} url = reverse("issues-detail", args=[issue.pk]) response = client.json.patch(url, json.dumps(data)) diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py index 45b7c96e..2a4f8e5c 100644 --- a/tests/integration/test_projects.py +++ b/tests/integration/test_projects.py @@ -265,7 +265,7 @@ def test_leave_project_respect_watching_items(client): url = reverse("projects-leave", args=(project.id,)) response = client.post(url) assert response.status_code == 200 - assert list(issue.watchers.all()) == [user] + assert issue.watchers == [user] def test_delete_membership_only_owner(client): diff --git a/tests/integration/test_timeline.py b/tests/integration/test_timeline.py index 957c5c91..00e61fc3 100644 --- a/tests/integration/test_timeline.py +++ b/tests/integration/test_timeline.py @@ -384,16 +384,6 @@ def test_assigned_to_user_story_timeline(): assert user_timeline[0].data["userstory"]["subject"] == "test us timeline" -def test_watchers_to_user_story_timeline(): - membership = factories.MembershipFactory.create() - user_story = factories.UserStoryFactory.create(subject="test us timeline", project=membership.project) - user_story.watchers.add(membership.user) - history_services.take_snapshot(user_story, user=user_story.owner) - user_timeline = service.get_profile_timeline(membership.user) - assert user_timeline[0].event_type == "userstories.userstory.create" - assert user_timeline[0].data["userstory"]["subject"] == "test us timeline" - - def test_user_data_for_non_system_users(): user_story = factories.UserStoryFactory.create(subject="test us timeline") history_services.take_snapshot(user_story, user=user_story.owner) diff --git a/tests/integration/test_watch_issues.py b/tests/integration/test_watch_issues.py new file mode 100644 index 00000000..f51d5075 --- /dev/null +++ b/tests/integration/test_watch_issues.py @@ -0,0 +1,47 @@ +# 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_watch_issue(client): + user = f.UserFactory.create() + issue = f.create_issue(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_owner=True) + url = reverse("issues-watch", args=(issue.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_unwatch_issue(client): + user = f.UserFactory.create() + issue = f.create_issue(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_owner=True) + url = reverse("issues-watch", args=(issue.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 diff --git a/tests/integration/test_watch_milestones.py b/tests/integration/test_watch_milestones.py new file mode 100644 index 00000000..72fea24d --- /dev/null +++ b/tests/integration/test_watch_milestones.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 +import json +from django.core.urlresolvers import reverse + +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_watch_milestone(client): + user = f.UserFactory.create() + milestone = f.MilestoneFactory(owner=user) + f.MembershipFactory.create(project=milestone.project, user=user, is_owner=True) + url = reverse("milestones-watch", args=(milestone.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_unwatch_milestone(client): + user = f.UserFactory.create() + milestone = f.MilestoneFactory(owner=user) + f.MembershipFactory.create(project=milestone.project, user=user, is_owner=True) + url = reverse("milestones-watch", args=(milestone.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_list_milestone_watchers(client): + user = f.UserFactory.create() + milestone = f.MilestoneFactory(owner=user) + f.MembershipFactory.create(project=milestone.project, user=user, is_owner=True) + f.WatchedFactory.create(content_object=milestone, user=user) + url = reverse("milestone-watchers-list", args=(milestone.id,)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data[0]['id'] == user.id + + +def test_get_milestone_watcher(client): + user = f.UserFactory.create() + milestone = f.MilestoneFactory(owner=user) + f.MembershipFactory.create(project=milestone.project, user=user, is_owner=True) + watch = f.WatchedFactory.create(content_object=milestone, user=user) + url = reverse("milestone-watchers-detail", args=(milestone.id, watch.user.id)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['id'] == watch.user.id + + +def test_get_milestone_watchers(client): + user = f.UserFactory.create() + milestone = f.MilestoneFactory(owner=user) + f.MembershipFactory.create(project=milestone.project, user=user, is_owner=True) + url = reverse("milestones-detail", args=(milestone.id,)) + + f.WatchedFactory.create(content_object=milestone, user=user) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['watchers'] == [user.id] + + +def test_get_milestone_is_watched(client): + user = f.UserFactory.create() + milestone = f.MilestoneFactory(owner=user) + f.MembershipFactory.create(project=milestone.project, user=user, is_owner=True) + url_detail = reverse("milestones-detail", args=(milestone.id,)) + url_watch = reverse("milestones-watch", args=(milestone.id,)) + url_unwatch = reverse("milestones-unwatch", args=(milestone.id,)) + + client.login(user) + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [] + assert response.data['is_watched'] == False + + response = client.post(url_watch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [user.id] + assert response.data['is_watched'] == True + + response = client.post(url_unwatch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [] + assert response.data['is_watched'] == False diff --git a/tests/integration/test_watch_projects.py b/tests/integration/test_watch_projects.py new file mode 100644 index 00000000..8bb765ce --- /dev/null +++ b/tests/integration/test_watch_projects.py @@ -0,0 +1,47 @@ +# 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_watch_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-watch", args=(project.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_unwacth_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-unwatch", args=(project.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 diff --git a/tests/integration/test_watch_tasks.py b/tests/integration/test_watch_tasks.py new file mode 100644 index 00000000..f62e4c7b --- /dev/null +++ b/tests/integration/test_watch_tasks.py @@ -0,0 +1,47 @@ +# 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_watch_task(client): + user = f.UserFactory.create() + task = f.create_task(owner=user) + f.MembershipFactory.create(project=task.project, user=user, is_owner=True) + url = reverse("tasks-watch", args=(task.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_unwatch_task(client): + user = f.UserFactory.create() + task = f.create_task(owner=user) + f.MembershipFactory.create(project=task.project, user=user, is_owner=True) + url = reverse("tasks-watch", args=(task.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 diff --git a/tests/integration/test_watch_userstories.py b/tests/integration/test_watch_userstories.py new file mode 100644 index 00000000..a6a7123e --- /dev/null +++ b/tests/integration/test_watch_userstories.py @@ -0,0 +1,47 @@ +# 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_watch_user_story(client): + user = f.UserFactory.create() + user_story = f.create_userstory(owner=user) + f.MembershipFactory.create(project=user_story.project, user=user, is_owner=True) + url = reverse("userstories-watch", args=(user_story.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_unwatch_user_story(client): + user = f.UserFactory.create() + user_story = f.create_userstory(owner=user) + f.MembershipFactory.create(project=user_story.project, user=user, is_owner=True) + url = reverse("userstories-unwatch", args=(user_story.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 diff --git a/tests/integration/test_watch_wikipages.py b/tests/integration/test_watch_wikipages.py new file mode 100644 index 00000000..c4f96bb6 --- /dev/null +++ b/tests/integration/test_watch_wikipages.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 +import json +from django.core.urlresolvers import reverse + +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +def test_watch_wikipage(client): + user = f.UserFactory.create() + wikipage = f.WikiPageFactory(owner=user) + f.MembershipFactory.create(project=wikipage.project, user=user, is_owner=True) + url = reverse("wiki-watch", args=(wikipage.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_unwatch_wikipage(client): + user = f.UserFactory.create() + wikipage = f.WikiPageFactory(owner=user) + f.MembershipFactory.create(project=wikipage.project, user=user, is_owner=True) + url = reverse("wiki-watch", args=(wikipage.id,)) + + client.login(user) + response = client.post(url) + + assert response.status_code == 200 + + +def test_list_wikipage_watchers(client): + user = f.UserFactory.create() + wikipage = f.WikiPageFactory(owner=user) + f.MembershipFactory.create(project=wikipage.project, user=user, is_owner=True) + f.WatchedFactory.create(content_object=wikipage, user=user) + url = reverse("wiki-watchers-list", args=(wikipage.id,)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data[0]['id'] == user.id + + +def test_get_wikipage_watcher(client): + user = f.UserFactory.create() + wikipage = f.WikiPageFactory(owner=user) + f.MembershipFactory.create(project=wikipage.project, user=user, is_owner=True) + watch = f.WatchedFactory.create(content_object=wikipage, user=user) + url = reverse("wiki-watchers-detail", args=(wikipage.id, watch.user.id)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['id'] == watch.user.id + + +def test_get_wikipage_watchers(client): + user = f.UserFactory.create() + wikipage = f.WikiPageFactory(owner=user) + f.MembershipFactory.create(project=wikipage.project, user=user, is_owner=True) + url = reverse("wiki-detail", args=(wikipage.id,)) + + f.WatchedFactory.create(content_object=wikipage, user=user) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['watchers'] == [user.id] + + +def test_get_wikipage_is_watched(client): + user = f.UserFactory.create() + wikipage = f.WikiPageFactory(owner=user) + f.MembershipFactory.create(project=wikipage.project, user=user, is_owner=True) + url_detail = reverse("wiki-detail", args=(wikipage.id,)) + url_watch = reverse("wiki-watch", args=(wikipage.id,)) + url_unwatch = reverse("wiki-unwatch", args=(wikipage.id,)) + + client.login(user) + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [] + assert response.data['is_watched'] == False + + response = client.post(url_watch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [user.id] + assert response.data['is_watched'] == True + + response = client.post(url_unwatch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [] + assert response.data['is_watched'] == False From bccdc2fae147740ddd006276ddbbd198618be396 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 18 Aug 2015 09:23:10 +0200 Subject: [PATCH 3/5] Adding votes and watched projects to sample_data --- taiga/projects/api.py | 6 +- taiga/projects/issues/api.py | 7 +- .../migrations/0006_remove_issue_watchers.py | 14 ++- taiga/projects/issues/permissions.py | 7 ++ taiga/projects/issues/services.py | 7 +- .../management/commands/sample_data.py | 24 +++- .../migrations/0024_auto_20150810_1247.py | 6 - .../0025_remove_project_watchers.py | 28 ----- taiga/projects/milestones/api.py | 13 +- .../0002_remove_milestone_watchers.py | 13 +- taiga/projects/milestones/permissions.py | 12 +- taiga/projects/milestones/serializers.py | 1 - taiga/projects/notifications/api.py | 22 +++- .../notifications/migrations/0004_watched.py | 15 ++- taiga/projects/notifications/mixins.py | 10 +- taiga/projects/notifications/models.py | 7 +- taiga/projects/notifications/serializers.py | 11 +- taiga/projects/notifications/services.py | 67 +++++++--- taiga/projects/permissions.py | 7 ++ taiga/projects/tasks/api.py | 7 +- .../migrations/0008_remove_task_watchers.py | 13 +- taiga/projects/tasks/permissions.py | 7 ++ taiga/projects/tasks/services.py | 6 +- taiga/projects/userstories/api.py | 7 +- .../0010_remove_userstory_watchers.py | 13 +- taiga/projects/userstories/permissions.py | 8 ++ taiga/projects/userstories/services.py | 6 +- taiga/projects/wiki/api.py | 15 ++- .../0002_remove_wikipage_watchers.py | 14 ++- taiga/projects/wiki/permissions.py | 13 +- taiga/projects/wiki/serializers.py | 4 - taiga/routers.py | 15 ++- taiga/timeline/signals.py | 8 +- tests/factories.py | 11 ++ .../test_issues_resources.py | 49 ++++++++ .../test_milestones_resources.py | 91 ++++++++++++++ .../test_projects_resource.py | 53 ++++++++ .../test_tasks_resources.py | 49 ++++++++ .../test_userstories_resources.py | 49 ++++++++ .../test_wiki_resources.py | 95 +++++++++++++- tests/integration/test_issues.py | 4 +- tests/integration/test_notifications.py | 118 ++++++++++++++++++ tests/integration/test_tasks.py | 4 +- tests/integration/test_timeline.py | 72 +++++++++++ tests/integration/test_userstories.py | 4 +- tests/integration/test_watch_issues.py | 76 +++++++++++ tests/integration/test_watch_projects.py | 76 +++++++++++ tests/integration/test_watch_tasks.py | 76 +++++++++++ tests/integration/test_watch_userstories.py | 76 +++++++++++ tests/integration/test_webhooks.py | 23 ++++ 50 files changed, 1205 insertions(+), 134 deletions(-) delete mode 100644 taiga/projects/migrations/0025_remove_project_watchers.py diff --git a/taiga/projects/api.py b/taiga/projects/api.py index 6223adb0..43f78475 100644 --- a/taiga/projects/api.py +++ b/taiga/projects/api.py @@ -31,7 +31,7 @@ from taiga.base.api.utils import get_object_or_404 from taiga.base.utils.slug import slugify_uniquely from taiga.projects.history.mixins import HistoryResourceMixin -from taiga.projects.notifications.mixins import WatchedResourceMixin +from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin from taiga.projects.mixins.ordering import BulkUpdateOrderMixin from taiga.projects.mixins.on_destroy import MoveOnDestroyMixin @@ -270,6 +270,10 @@ class ProjectFansViewSet(VotersViewSetMixin, ModelListViewSet): resource_model = models.Project +class ProjectWatchersViewSet(WatchersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.ProjectWatchersPermission,) + resource_model = models.Project + ###################################################### ## Custom values for selectors ###################################################### diff --git a/taiga/projects/issues/api.py b/taiga/projects/issues/api.py index 1007bdf1..5035418b 100644 --- a/taiga/projects/issues/api.py +++ b/taiga/projects/issues/api.py @@ -27,7 +27,7 @@ from taiga.base.api.utils import get_object_or_404 from taiga.users.models import User -from taiga.projects.notifications.mixins import WatchedResourceMixin +from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin from taiga.projects.occ import OCCResourceMixin from taiga.projects.history.mixins import HistoryResourceMixin @@ -243,3 +243,8 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W class IssueVotersViewSet(VotersViewSetMixin, ModelListViewSet): permission_classes = (permissions.IssueVotersPermission,) resource_model = models.Issue + + +class IssueWatchersViewSet(WatchersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.IssueWatchersPermission,) + resource_model = models.Issue diff --git a/taiga/projects/issues/migrations/0006_remove_issue_watchers.py b/taiga/projects/issues/migrations/0006_remove_issue_watchers.py index c41e387e..dd3ee037 100644 --- a/taiga/projects/issues/migrations/0006_remove_issue_watchers.py +++ b/taiga/projects/issues/migrations/0006_remove_issue_watchers.py @@ -1,17 +1,19 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.db import connection from django.db import models, migrations from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.management import update_all_contenttypes def create_notifications(apps, schema_editor): - update_all_contenttypes() - migrations.RunSQL(sql=""" -INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id) -SELECT issue_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id -FROM issues_issue_watchers""".format(content_type_id=ContentType.objects.get(model='issue').id)) - + update_all_contenttypes() + sql=""" +INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id, project_id) +SELECT issue_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id, project_id +FROM issues_issue_watchers INNER JOIN issues_issue ON issues_issue_watchers.issue_id = issues_issue.id""".format(content_type_id=ContentType.objects.get(model='issue').id) + cursor = connection.cursor() + cursor.execute(sql) class Migration(migrations.Migration): diff --git a/taiga/projects/issues/permissions.py b/taiga/projects/issues/permissions.py index 82120e14..91f988ca 100644 --- a/taiga/projects/issues/permissions.py +++ b/taiga/projects/issues/permissions.py @@ -52,3 +52,10 @@ class IssueVotersPermission(TaigaResourcePermission): global_perms = None retrieve_perms = HasProjectPerm('view_issues') list_perms = HasProjectPerm('view_issues') + + +class IssueWatchersPermission(TaigaResourcePermission): + enought_perms = IsProjectOwner() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_issues') + list_perms = HasProjectPerm('view_issues') diff --git a/taiga/projects/issues/services.py b/taiga/projects/issues/services.py index 9a553cd3..d1156227 100644 --- a/taiga/projects/issues/services.py +++ b/taiga/projects/issues/services.py @@ -27,7 +27,7 @@ from taiga.base.utils import db, text from taiga.projects.issues.apps import ( connect_issues_signals, disconnect_issues_signals) - +from taiga.projects.votes import services as votes_services from . import models @@ -84,7 +84,8 @@ def issues_to_csv(project, queryset): fieldnames = ["ref", "subject", "description", "milestone", "owner", "owner_full_name", "assigned_to", "assigned_to_full_name", "status", "severity", "priority", "type", "is_closed", - "attachments", "external_reference", "tags"] + "attachments", "external_reference", "tags", + "watchers", "voters"] for custom_attr in project.issuecustomattributes.all(): fieldnames.append(custom_attr.name) @@ -108,6 +109,8 @@ def issues_to_csv(project, queryset): "attachments": issue.attachments.count(), "external_reference": issue.external_reference, "tags": ",".join(issue.tags or []), + "watchers": [u.id for u in issue.get_watchers()], + "voters": votes_services.get_voters(issue).count(), } for custom_attr in project.issuecustomattributes.all(): diff --git a/taiga/projects/management/commands/sample_data.py b/taiga/projects/management/commands/sample_data.py index 0a5e583e..6c5a4bcc 100644 --- a/taiga/projects/management/commands/sample_data.py +++ b/taiga/projects/management/commands/sample_data.py @@ -37,6 +37,7 @@ from taiga.projects.wiki.models import * from taiga.projects.attachments.models import * from taiga.projects.custom_attributes.models import * from taiga.projects.history.services import take_snapshot +from taiga.projects.votes.services import add_vote from taiga.events.apps import disconnect_events_signals @@ -97,7 +98,8 @@ 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)) class Command(BaseCommand): sd = SampleDataHelper(seed=12345678901) @@ -215,6 +217,7 @@ class Command(BaseCommand): project.total_story_points = int(defined_points * self.sd.int(5,12) / 10) project.save() + self.create_votes(project, project) def create_attachment(self, obj, order): attached_file = self.sd.file_from_directory(*ATTACHMENT_SAMPLE_DATA) @@ -287,7 +290,7 @@ class Command(BaseCommand): bug.save() watching_user = self.sd.db_object_from_queryset(project.memberships.filter(user__isnull=False)).user - bug.watchers.add(watching_user) + bug.add_watcher(watching_user) take_snapshot(bug, comment=self.sd.paragraph(), @@ -300,6 +303,7 @@ class Command(BaseCommand): comment=self.sd.paragraph(), user=bug.owner) + self.create_votes(bug, project) return bug def create_task(self, project, milestone, us, min_date, max_date, closed=False): @@ -338,7 +342,7 @@ class Command(BaseCommand): user=task.owner) watching_user = self.sd.db_object_from_queryset(project.memberships.filter(user__isnull=False)).user - task.watchers.add(watching_user) + task.add_watcher(watching_user) # Add history entry task.status=self.sd.db_object_from_queryset(project.task_statuses.all()) @@ -347,6 +351,7 @@ class Command(BaseCommand): comment=self.sd.paragraph(), user=task.owner) + self.create_votes(task, project) return task def create_us(self, project, milestone=None, computable_project_roles=[]): @@ -387,7 +392,7 @@ class Command(BaseCommand): us.save() watching_user = self.sd.db_object_from_queryset(project.memberships.filter(user__isnull=False)).user - us.watchers.add(watching_user) + us.add_watcher(watching_user) take_snapshot(us, comment=self.sd.paragraph(), @@ -400,6 +405,7 @@ class Command(BaseCommand): comment=self.sd.paragraph(), user=us.owner) + self.create_votes(us, project) return us def create_milestone(self, project, start_date, end_date): @@ -434,6 +440,11 @@ class Command(BaseCommand): project.is_kanban_activated = True 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) + return project def create_user(self, counter=None, username=None, full_name=None, email=None): @@ -452,3 +463,8 @@ class Command(BaseCommand): user.save() return user + + def create_votes(self, obj, project): + 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) diff --git a/taiga/projects/migrations/0024_auto_20150810_1247.py b/taiga/projects/migrations/0024_auto_20150810_1247.py index ebe758fc..f057816b 100644 --- a/taiga/projects/migrations/0024_auto_20150810_1247.py +++ b/taiga/projects/migrations/0024_auto_20150810_1247.py @@ -14,12 +14,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AddField( - model_name='project', - name='watchers', - field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL, related_name='projects_project+', null=True, verbose_name='watchers'), - preserve_default=True, - ), migrations.AlterField( model_name='project', name='public_permissions', diff --git a/taiga/projects/migrations/0025_remove_project_watchers.py b/taiga/projects/migrations/0025_remove_project_watchers.py deleted file mode 100644 index 5748edc9..00000000 --- a/taiga/projects/migrations/0025_remove_project_watchers.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -from django.contrib.contenttypes.models import ContentType -from django.contrib.contenttypes.management import update_all_contenttypes - -def create_notifications(apps, schema_editor): - update_all_contenttypes() - migrations.RunSQL(sql=""" -INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id) -SELECT project_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id -FROM projects_project_watchers""".format(content_type_id=ContentType.objects.get(model='project').id)) - - -class Migration(migrations.Migration): - dependencies = [ - ('notifications', '0004_watched'), - ('projects', '0024_auto_20150810_1247'), - ] - - operations = [ - migrations.RunPython(create_notifications), - migrations.RemoveField( - model_name='project', - name='watchers', - ), - ] diff --git a/taiga/projects/milestones/api.py b/taiga/projects/milestones/api.py index 93a09831..3194dc39 100644 --- a/taiga/projects/milestones/api.py +++ b/taiga/projects/milestones/api.py @@ -17,10 +17,10 @@ from taiga.base import filters from taiga.base import response from taiga.base.decorators import detail_route -from taiga.base.api import ModelCrudViewSet +from taiga.base.api import ModelCrudViewSet, ModelListViewSet from taiga.base.api.utils import get_object_or_404 -from taiga.projects.notifications.mixins import WatchedResourceMixin +from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin from taiga.projects.history.mixins import HistoryResourceMixin @@ -36,9 +36,11 @@ class MilestoneViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCrudView permission_classes = (permissions.MilestonePermission,) filter_backends = (filters.CanViewMilestonesFilterBackend,) filter_fields = ("project", "closed") + queryset = models.Milestone.objects.all() def get_queryset(self): - qs = models.Milestone.objects.all() + qs = super().get_queryset() + qs = self.attach_watchers_attrs_to_queryset(qs) qs = qs.prefetch_related("user_stories", "user_stories__role_points", "user_stories__role_points__points", @@ -91,3 +93,8 @@ class MilestoneViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCrudView optimal_points -= optimal_points_per_day return response.Ok(milestone_stats) + + +class MilestoneWatchersViewSet(WatchersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.MilestoneWatchersPermission,) + resource_model = models.Milestone diff --git a/taiga/projects/milestones/migrations/0002_remove_milestone_watchers.py b/taiga/projects/milestones/migrations/0002_remove_milestone_watchers.py index 897f47bf..69d6aacd 100644 --- a/taiga/projects/milestones/migrations/0002_remove_milestone_watchers.py +++ b/taiga/projects/milestones/migrations/0002_remove_milestone_watchers.py @@ -1,16 +1,19 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.db import connection from django.db import models, migrations from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.management import update_all_contenttypes def create_notifications(apps, schema_editor): - update_all_contenttypes() - migrations.RunSQL(sql=""" -INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id) -SELECT milestone_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id -FROM milestones_milestone_watchers""".format(content_type_id=ContentType.objects.get(model='milestone').id)), + update_all_contenttypes() + sql=""" +INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id, project_id) +SELECT milestone_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id, project_id +FROM milestones_milestone_watchers INNER JOIN milestones_milestone ON milestones_milestone_watchers.milestone_id = milestones_milestone.id""".format(content_type_id=ContentType.objects.get(model='milestone').id) + cursor = connection.cursor() + cursor.execute(sql) class Migration(migrations.Migration): diff --git a/taiga/projects/milestones/permissions.py b/taiga/projects/milestones/permissions.py index 9823c8de..843c0c8a 100644 --- a/taiga/projects/milestones/permissions.py +++ b/taiga/projects/milestones/permissions.py @@ -15,8 +15,8 @@ # along with this program. If not, see . from taiga.base.api.permissions import (TaigaResourcePermission, HasProjectPerm, - IsProjectOwner, AllowAny, - PermissionComponent, IsSuperUser) + IsAuthenticated, IsProjectOwner, AllowAny, + IsSuperUser) class MilestonePermission(TaigaResourcePermission): @@ -29,3 +29,11 @@ class MilestonePermission(TaigaResourcePermission): destroy_perms = HasProjectPerm('delete_milestone') list_perms = AllowAny() stats_perms = HasProjectPerm('view_milestones') + watch_perms = IsAuthenticated() & HasProjectPerm('view_milestones') + unwatch_perms = IsAuthenticated() & HasProjectPerm('view_milestones') + +class MilestoneWatchersPermission(TaigaResourcePermission): + enought_perms = IsProjectOwner() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_milestones') + list_perms = HasProjectPerm('view_milestones') diff --git a/taiga/projects/milestones/serializers.py b/taiga/projects/milestones/serializers.py index a2a483e5..471b9546 100644 --- a/taiga/projects/milestones/serializers.py +++ b/taiga/projects/milestones/serializers.py @@ -17,7 +17,6 @@ from django.utils.translation import ugettext as _ from taiga.base.api import serializers - from taiga.base.utils import json from taiga.projects.notifications.mixins import WatchedResourceModelSerializer from taiga.projects.notifications.validators import WatchersValidator diff --git a/taiga/projects/notifications/api.py b/taiga/projects/notifications/api.py index 2431c7c2..b2f2d260 100644 --- a/taiga/projects/notifications/api.py +++ b/taiga/projects/notifications/api.py @@ -19,8 +19,9 @@ from django.db.models import Q from taiga.base.api import ModelCrudViewSet from taiga.projects.notifications.choices import NotifyLevel +from taiga.projects.notifications.models import Watched from taiga.projects.models import Project - +from taiga.users import services as user_services from . import serializers from . import models from . import permissions @@ -32,9 +33,13 @@ class NotifyPolicyViewSet(ModelCrudViewSet): permission_classes = (permissions.NotifyPolicyPermission,) def _build_needed_notify_policies(self): + watched_content = user_services.get_watched_content_for_user(self.request.user) + watched_content_project_ids = watched_content.values_list("project__id", flat=True).distinct() + projects = Project.objects.filter( Q(owner=self.request.user) | - Q(memberships__user=self.request.user) + Q(memberships__user=self.request.user) | + Q(id__in=watched_content_project_ids) ).distinct() for project in projects: @@ -45,5 +50,14 @@ class NotifyPolicyViewSet(ModelCrudViewSet): return models.NotifyPolicy.objects.none() self._build_needed_notify_policies() - qs = models.NotifyPolicy.objects.filter(user=self.request.user) - return qs.distinct() + + # With really want to include the policies related to any content: + # - The user is the owner of the project + # - The user is member of the project + # - The user is watching any object from the project + watched_content = user_services.get_watched_content_for_user(self.request.user) + watched_content_project_ids = watched_content.values_list("project__id", flat=True).distinct() + return models.NotifyPolicy.objects.filter(Q(project__owner=self.request.user) | + Q(project__memberships__user=self.request.user) | + Q(project__id__in=watched_content_project_ids) + ).distinct() diff --git a/taiga/projects/notifications/migrations/0004_watched.py b/taiga/projects/notifications/migrations/0004_watched.py index 53c85560..ab0878b7 100644 --- a/taiga/projects/notifications/migrations/0004_watched.py +++ b/taiga/projects/notifications/migrations/0004_watched.py @@ -5,10 +5,6 @@ from django.db import models, migrations from django.conf import settings -def fill_watched_table(apps, schema_editor): - Watched = apps.get_model("notifications", "Watched") - print("test") - class Migration(migrations.Migration): dependencies = [ @@ -21,15 +17,22 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Watched', fields=[ - ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('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='watched', verbose_name='user', to=settings.AUTH_USER_MODEL)), + ('project', models.ForeignKey(to='projects.Project', verbose_name='project', related_name='watched')), + ], options={ + 'verbose_name': 'Watched', + 'verbose_name_plural': 'Watched', }, bases=(models.Model,), ), - migrations.RunPython(fill_watched_table), + migrations.AlterUniqueTogether( + name='watched', + unique_together=set([('content_type', 'object_id', 'user', 'project')]), + ), ] diff --git a/taiga/projects/notifications/mixins.py b/taiga/projects/notifications/mixins.py index c5ffbb84..75740a3d 100644 --- a/taiga/projects/notifications/mixins.py +++ b/taiga/projects/notifications/mixins.py @@ -19,17 +19,21 @@ from operator import is_not from django.apps import apps from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist from django.db import models from django.utils.translation import ugettext_lazy as _ from taiga.base import response from taiga.base.decorators import detail_route from taiga.base.api import serializers +from taiga.base.api.utils import get_object_or_404 from taiga.base.fields import WatchersField from taiga.projects.notifications import services from taiga.projects.notifications.utils import attach_watchers_to_queryset, attach_is_watched_to_queryset from taiga.users.models import User from . import models +from . serializers import WatcherSerializer + class WatchedResourceMixin: @@ -121,7 +125,6 @@ class WatchedModelMixin(object): that should works in almost all cases. """ return self.project - t def get_watchers(self) -> frozenset: """ @@ -139,6 +142,9 @@ class WatchedModelMixin(object): """ return frozenset(services.get_watchers(self)) + def get_watched(self, user_or_id): + return services.get_watched(user_or_id, type(self)) + def add_watcher(self, user): services.add_watcher(self, user) @@ -209,7 +215,7 @@ class WatchedResourceModelSerializer(serializers.ModelSerializer): def to_native(self, obj): #watchers is wasn't attached via the get_queryset of the viewset we need to manually add it if not hasattr(obj, "watchers"): - obj.watchers = services.get_watchers(obj) + obj.watchers = [user.id for user in services.get_watchers(obj)] return super(WatchedResourceModelSerializer, self).to_native(obj) diff --git a/taiga/projects/notifications/models.py b/taiga/projects/notifications/models.py index 753b8878..603bdd85 100644 --- a/taiga/projects/notifications/models.py +++ b/taiga/projects/notifications/models.py @@ -22,7 +22,7 @@ from django.utils import timezone from taiga.projects.history.choices import HISTORY_TYPE_CHOICES -from .choices import NOTIFY_LEVEL_CHOICES +from .choices import NOTIFY_LEVEL_CHOICES, NotifyLevel class NotifyPolicy(models.Model): @@ -84,8 +84,9 @@ class Watched(models.Model): related_name="watched", verbose_name=_("user")) created_date = models.DateTimeField(auto_now_add=True, null=False, blank=False, verbose_name=_("created date")) - + project = models.ForeignKey("projects.Project", null=False, blank=False, + verbose_name=_("project"),related_name="watched") class Meta: verbose_name = _("Watched") verbose_name_plural = _("Watched") - unique_together = ("content_type", "object_id", "user") + unique_together = ("content_type", "object_id", "user", "project") diff --git a/taiga/projects/notifications/serializers.py b/taiga/projects/notifications/serializers.py index c60e4bc9..9b0b99cd 100644 --- a/taiga/projects/notifications/serializers.py +++ b/taiga/projects/notifications/serializers.py @@ -17,9 +17,10 @@ import json from taiga.base.api import serializers +from taiga.users.models import User from . import models - +from . import choices class NotifyPolicySerializer(serializers.ModelSerializer): @@ -31,3 +32,11 @@ class NotifyPolicySerializer(serializers.ModelSerializer): def get_project_name(self, obj): return obj.project.name + + +class WatcherSerializer(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/notifications/services.py b/taiga/projects/notifications/services.py index be19a493..adc94a69 100644 --- a/taiga/projects/notifications/services.py +++ b/taiga/projects/notifications/services.py @@ -20,6 +20,7 @@ from django.apps import apps from django.db.transaction import atomic from django.db import IntegrityError, transaction from django.contrib.contenttypes.models import ContentType +from django.contrib.auth import get_user_model from django.utils import timezone from django.conf import settings from django.utils.translation import ugettext as _ @@ -170,15 +171,19 @@ def get_users_to_notify(obj, *, discard_users=None) -> list: candidates = set() candidates.update(filter(_can_notify_hard, project.members.all())) candidates.update(filter(_can_notify_light, obj.get_watchers())) + candidates.update(filter(_can_notify_light, obj.project.get_watchers())) candidates.update(filter(_can_notify_light, obj.get_participants())) + #TODO: coger los watchers del proyecto que quieren ser notificados por correo + #Filtrar los watchers según su nivel de watched y su nivel en el proyecto + # Remove the changer from candidates if discard_users: candidates = candidates - set(discard_users) - candidates = filter(partial(_filter_by_permissions, obj), candidates) + candidates = set(filter(partial(_filter_by_permissions, obj), candidates)) # Filter disabled and system users - candidates = filter(partial(_filter_notificable), candidates) + candidates = set(filter(partial(_filter_notificable), candidates)) return frozenset(candidates) @@ -285,27 +290,54 @@ def process_sync_notifications(): def get_watchers(obj): - User = apps.get_model("users", "User") - Watched = apps.get_model("notifications", "Watched") - content_type = ContentType.objects.get_for_model(obj) - watching_user_ids = Watched.objects.filter(content_type=content_type, object_id=obj.id).values_list("user__id", flat=True) - return User.objects.filter(id__in=watching_user_ids) + """Get the watchers of an object. + + :param obj: Any Django model instance. + + :return: User queryset object representing the users that voted the object. + """ + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj) + return get_user_model().objects.filter(watched__content_type=obj_type, watched__object_id=obj.id) + + +def get_watched(user_or_id, model): + """Get the objects watched 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 votes of the user. + """ + obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model) + conditions = ('notifications_watched.content_type_id = %s', + '%s.id = notifications_watched.object_id' % model._meta.db_table, + 'notifications_watched.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=('notifications_watched',), + params=(obj_type.id, user_id)) def add_watcher(obj, user): """Add a watcher to an object. - If the user is already watching the object nothing happends, so this function can be considered - idempotent. + If the user is already watching the object nothing happents (except if there is a level update), + so this function can be considered idempotent. :param obj: Any Django model instance. :param user: User adding the watch. :class:`~taiga.users.models.User` instance. """ obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj) - with atomic(): - watched, created = Watched.objects.get_or_create(content_type=obj_type, object_id=obj.id, user=user) - if not created: - return + watched, created = Watched.objects.get_or_create(content_type=obj_type, + object_id=obj.id, user=user, project=obj.project) + + notify_policy, _ = apps.get_model("notifications", "NotifyPolicy").objects.get_or_create( + project=obj.project, user=user, defaults={"notify_level": NotifyLevel.watch}) + return watched @@ -319,9 +351,8 @@ def remove_watcher(obj, user): :param user: User removing the watch. :class:`~taiga.users.models.User` instance. """ obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj) - with atomic(): - qs = Watched.objects.filter(content_type=obj_type, object_id=obj.id, user=user) - if not qs.exists(): - return + qs = Watched.objects.filter(content_type=obj_type, object_id=obj.id, user=user) + if not qs.exists(): + return - qs.delete() + qs.delete() diff --git a/taiga/projects/permissions.py b/taiga/projects/permissions.py index 9cabdc97..1ec6f984 100644 --- a/taiga/projects/permissions.py +++ b/taiga/projects/permissions.py @@ -75,6 +75,13 @@ class ProjectFansPermission(TaigaResourcePermission): list_perms = HasProjectPerm('view_project') +class ProjectWatchersPermission(TaigaResourcePermission): + enought_perms = IsProjectOwner() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_project') + list_perms = HasProjectPerm('view_project') + + class MembershipPermission(TaigaResourcePermission): retrieve_perms = HasProjectPerm('view_project') create_perms = IsProjectOwner() diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py index 2e2ad619..812458f1 100644 --- a/taiga/projects/tasks/api.py +++ b/taiga/projects/tasks/api.py @@ -24,7 +24,7 @@ from taiga.base.api import ModelCrudViewSet, ModelListViewSet from taiga.projects.models import Project, TaskStatus from django.http import HttpResponse -from taiga.projects.notifications.mixins import WatchedResourceMixin +from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin from taiga.projects.history.mixins import HistoryResourceMixin from taiga.projects.occ import OCCResourceMixin from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin @@ -177,3 +177,8 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa class TaskVotersViewSet(VotersViewSetMixin, ModelListViewSet): permission_classes = (permissions.TaskVotersPermission,) resource_model = models.Task + + +class TaskWatchersViewSet(WatchersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.TaskWatchersPermission,) + resource_model = models.Task diff --git a/taiga/projects/tasks/migrations/0008_remove_task_watchers.py b/taiga/projects/tasks/migrations/0008_remove_task_watchers.py index 813eaad9..4c934957 100644 --- a/taiga/projects/tasks/migrations/0008_remove_task_watchers.py +++ b/taiga/projects/tasks/migrations/0008_remove_task_watchers.py @@ -1,16 +1,19 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.db import connection from django.db import models, migrations from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.management import update_all_contenttypes def create_notifications(apps, schema_editor): - update_all_contenttypes() - migrations.RunSQL(sql=""" -INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id) -SELECT task_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id -FROM tasks_task_watchers""".format(content_type_id=ContentType.objects.get(model='task').id)), + update_all_contenttypes() + sql=""" +INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id, project_id) +SELECT task_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id, project_id +FROM tasks_task_watchers INNER JOIN tasks_task ON tasks_task_watchers.task_id = tasks_task.id""".format(content_type_id=ContentType.objects.get(model='task').id) + cursor = connection.cursor() + cursor.execute(sql) class Migration(migrations.Migration): diff --git a/taiga/projects/tasks/permissions.py b/taiga/projects/tasks/permissions.py index c9cbd667..cf12a283 100644 --- a/taiga/projects/tasks/permissions.py +++ b/taiga/projects/tasks/permissions.py @@ -42,3 +42,10 @@ class TaskVotersPermission(TaigaResourcePermission): global_perms = None retrieve_perms = HasProjectPerm('view_tasks') list_perms = HasProjectPerm('view_tasks') + + +class TaskWatchersPermission(TaigaResourcePermission): + enought_perms = IsProjectOwner() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_tasks') + list_perms = HasProjectPerm('view_tasks') diff --git a/taiga/projects/tasks/services.py b/taiga/projects/tasks/services.py index 61225aff..8864d893 100644 --- a/taiga/projects/tasks/services.py +++ b/taiga/projects/tasks/services.py @@ -23,6 +23,7 @@ from taiga.projects.tasks.apps import ( connect_tasks_signals, disconnect_tasks_signals) from taiga.events import events +from taiga.projects.votes import services as votes_services from . import models @@ -95,7 +96,8 @@ def tasks_to_csv(project, queryset): fieldnames = ["ref", "subject", "description", "user_story", "milestone", "owner", "owner_full_name", "assigned_to", "assigned_to_full_name", "status", "is_iocaine", "is_closed", "us_order", - "taskboard_order", "attachments", "external_reference", "tags"] + "taskboard_order", "attachments", "external_reference", "tags", + "watchers", "voters"] for custom_attr in project.taskcustomattributes.all(): fieldnames.append(custom_attr.name) @@ -120,6 +122,8 @@ def tasks_to_csv(project, queryset): "attachments": task.attachments.count(), "external_reference": task.external_reference, "tags": ",".join(task.tags or []), + "watchers": [u.id for u in task.get_watchers()], + "voters": votes_services.get_voters(task).count(), } for custom_attr in project.taskcustomattributes.all(): value = task.custom_attributes_values.attributes_values.get(str(custom_attr.id), None) diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index 653fdc56..802e3e73 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -31,7 +31,7 @@ from taiga.base.decorators import list_route from taiga.base.api import ModelCrudViewSet, ModelListViewSet from taiga.base.api.utils import get_object_or_404 -from taiga.projects.notifications.mixins import WatchedResourceMixin +from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin from taiga.projects.history.mixins import HistoryResourceMixin from taiga.projects.occ import OCCResourceMixin from taiga.projects.models import Project, UserStoryStatus @@ -270,3 +270,8 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi class UserStoryVotersViewSet(VotersViewSetMixin, ModelListViewSet): permission_classes = (permissions.UserStoryVotersPermission,) resource_model = models.UserStory + + +class UserStoryWatchersViewSet(WatchersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.UserStoryWatchersPermission,) + resource_model = models.UserStory diff --git a/taiga/projects/userstories/migrations/0010_remove_userstory_watchers.py b/taiga/projects/userstories/migrations/0010_remove_userstory_watchers.py index d3e24f62..0d897aca 100644 --- a/taiga/projects/userstories/migrations/0010_remove_userstory_watchers.py +++ b/taiga/projects/userstories/migrations/0010_remove_userstory_watchers.py @@ -1,16 +1,19 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.db import connection from django.db import models, migrations from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.management import update_all_contenttypes def create_notifications(apps, schema_editor): - update_all_contenttypes() - migrations.RunSQL(sql=""" -INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id) -SELECT userstory_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id -FROM userstories_userstory_watchers""".format(content_type_id=ContentType.objects.get(model='userstory').id)), + update_all_contenttypes() + sql=""" +INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id, project_id) +SELECT userstory_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id, project_id +FROM userstories_userstory_watchers INNER JOIN userstories_userstory ON userstories_userstory_watchers.userstory_id = userstories_userstory.id""".format(content_type_id=ContentType.objects.get(model='userstory').id) + cursor = connection.cursor() + cursor.execute(sql) class Migration(migrations.Migration): diff --git a/taiga/projects/userstories/permissions.py b/taiga/projects/userstories/permissions.py index 0a1c7b8a..fb9361ab 100644 --- a/taiga/projects/userstories/permissions.py +++ b/taiga/projects/userstories/permissions.py @@ -35,8 +35,16 @@ class UserStoryPermission(TaigaResourcePermission): watch_perms = IsAuthenticated() & HasProjectPerm('view_us') unwatch_perms = IsAuthenticated() & HasProjectPerm('view_us') + class UserStoryVotersPermission(TaigaResourcePermission): enought_perms = IsProjectOwner() | IsSuperUser() global_perms = None retrieve_perms = HasProjectPerm('view_us') list_perms = HasProjectPerm('view_us') + + +class UserStoryWatchersPermission(TaigaResourcePermission): + enought_perms = IsProjectOwner() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_us') + list_perms = HasProjectPerm('view_us') diff --git a/taiga/projects/userstories/services.py b/taiga/projects/userstories/services.py index 9d913707..c06309be 100644 --- a/taiga/projects/userstories/services.py +++ b/taiga/projects/userstories/services.py @@ -31,6 +31,7 @@ from taiga.projects.userstories.apps import ( disconnect_userstories_signals) from taiga.events import events +from taiga.projects.votes import services as votes_services from . import models @@ -138,7 +139,8 @@ def userstories_to_csv(project,queryset): "created_date", "modified_date", "finish_date", "client_requirement", "team_requirement", "attachments", "generated_from_issue", "external_reference", "tasks", - "tags"] + "tags", + "watchers", "voters"] for custom_attr in project.userstorycustomattributes.all(): fieldnames.append(custom_attr.name) @@ -170,6 +172,8 @@ def userstories_to_csv(project,queryset): "external_reference": us.external_reference, "tasks": ",".join([str(task.ref) for task in us.tasks.all()]), "tags": ",".join(us.tags or []), + "watchers": [u.id for u in us.get_watchers()], + "voters": votes_services.get_voters(us).count(), } for role in us.project.roles.filter(computable=True).order_by('name'): diff --git a/taiga/projects/wiki/api.py b/taiga/projects/wiki/api.py index a2e39eda..dba3ccb0 100644 --- a/taiga/projects/wiki/api.py +++ b/taiga/projects/wiki/api.py @@ -21,13 +21,13 @@ from taiga.base.api.permissions import IsAuthenticated from taiga.base import filters from taiga.base import exceptions as exc from taiga.base import response -from taiga.base.api import ModelCrudViewSet +from taiga.base.api import ModelCrudViewSet, ModelListViewSet from taiga.base.api.utils import get_object_or_404 from taiga.base.decorators import list_route from taiga.projects.models import Project from taiga.mdrender.service import render as mdrender -from taiga.projects.notifications.mixins import WatchedResourceMixin +from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin from taiga.projects.history.mixins import HistoryResourceMixin from taiga.projects.occ import OCCResourceMixin @@ -43,6 +43,12 @@ class WikiViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, permission_classes = (permissions.WikiPagePermission,) filter_backends = (filters.CanViewWikiPagesFilterBackend,) filter_fields = ("project", "slug") + queryset = models.WikiPage.objects.all() + + def get_queryset(self): + qs = super().get_queryset() + qs = self.attach_watchers_attrs_to_queryset(qs) + return qs @list_route(methods=["GET"]) def by_slug(self, request): @@ -77,6 +83,11 @@ class WikiViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, super().pre_save(obj) +class WikiWatchersViewSet(WatchersViewSetMixin, ModelListViewSet): + permission_classes = (permissions.WikiPageWatchersPermission,) + resource_model = models.WikiPage + + class WikiLinkViewSet(ModelCrudViewSet): model = models.WikiLink serializer_class = serializers.WikiLinkSerializer diff --git a/taiga/projects/wiki/migrations/0002_remove_wikipage_watchers.py b/taiga/projects/wiki/migrations/0002_remove_wikipage_watchers.py index d0c1c832..f2cb8159 100644 --- a/taiga/projects/wiki/migrations/0002_remove_wikipage_watchers.py +++ b/taiga/projects/wiki/migrations/0002_remove_wikipage_watchers.py @@ -1,17 +1,19 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.apps import apps +from django.db import connection from django.db import models, migrations from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.management import update_all_contenttypes def create_notifications(apps, schema_editor): - update_all_contenttypes() - migrations.RunSQL(sql=""" -INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id) -SELECT wikipage_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id -FROM wiki_wikipage_watchers""".format(content_type_id=ContentType.objects.get(model='wikipage').id)), + update_all_contenttypes() + sql=""" +INSERT INTO notifications_watched (object_id, created_date, content_type_id, user_id, project_id) +SELECT wikipage_id AS object_id, now() AS created_date, {content_type_id} AS content_type_id, user_id, project_id +FROM wiki_wikipage_watchers INNER JOIN wiki_wikipage ON wiki_wikipage_watchers.wikipage_id = wiki_wikipage.id""".format(content_type_id=ContentType.objects.get(model='wikipage').id) + cursor = connection.cursor() + cursor.execute(sql) class Migration(migrations.Migration): diff --git a/taiga/projects/wiki/permissions.py b/taiga/projects/wiki/permissions.py index 684880a8..c64ac985 100644 --- a/taiga/projects/wiki/permissions.py +++ b/taiga/projects/wiki/permissions.py @@ -15,7 +15,8 @@ # along with this program. If not, see . from taiga.base.api.permissions import (TaigaResourcePermission, HasProjectPerm, - IsProjectOwner, AllowAny, IsSuperUser) + IsAuthenticated, IsProjectOwner, AllowAny, + IsSuperUser) class WikiPagePermission(TaigaResourcePermission): @@ -29,6 +30,16 @@ class WikiPagePermission(TaigaResourcePermission): destroy_perms = HasProjectPerm('delete_wiki_page') list_perms = AllowAny() render_perms = AllowAny() + watch_perms = IsAuthenticated() & HasProjectPerm('view_wiki_pages') + unwatch_perms = IsAuthenticated() & HasProjectPerm('view_wiki_pages') + + +class WikiPageWatchersPermission(TaigaResourcePermission): + enought_perms = IsProjectOwner() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_wiki_pages') + list_perms = HasProjectPerm('view_wiki_pages') + class WikiLinkPermission(TaigaResourcePermission): enought_perms = IsProjectOwner() | IsSuperUser() diff --git a/taiga/projects/wiki/serializers.py b/taiga/projects/wiki/serializers.py index a528fdd8..22c9b8bd 100644 --- a/taiga/projects/wiki/serializers.py +++ b/taiga/projects/wiki/serializers.py @@ -22,10 +22,6 @@ from taiga.mdrender.service import render as mdrender from . import models -from taiga.projects.history import services as history_service - -from taiga.mdrender.service import render as mdrender - class WikiPageSerializer(WatchersValidator, WatchedResourceModelSerializer, serializers.ModelSerializer): html = serializers.SerializerMethodField("get_html") diff --git a/taiga/routers.py b/taiga/routers.py index ff7ceff0..5a587b59 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -49,6 +49,7 @@ router.register(r"notify-policies", NotifyPolicyViewSet, base_name="notification # Projects & Selectors from taiga.projects.api import ProjectViewSet from taiga.projects.api import ProjectFansViewSet +from taiga.projects.api import ProjectWatchersViewSet from taiga.projects.api import MembershipViewSet from taiga.projects.api import InvitationViewSet from taiga.projects.api import UserStoryStatusViewSet @@ -62,6 +63,7 @@ from taiga.projects.api import ProjectTemplateViewSet router.register(r"projects", ProjectViewSet, base_name="projects") router.register(r"projects/(?P\d+)/fans", ProjectFansViewSet, base_name="project-fans") +router.register(r"projects/(?P\d+)/watchers", ProjectWatchersViewSet, base_name="project-watchers") router.register(r"project-templates", ProjectTemplateViewSet, base_name="project-templates") router.register(r"memberships", MembershipViewSet, base_name="memberships") router.register(r"invitations", InvitationViewSet, base_name="invitations") @@ -124,22 +126,33 @@ router.register(r"wiki/attachments", WikiAttachmentViewSet, base_name="wiki-atta # Project components from taiga.projects.milestones.api import MilestoneViewSet +from taiga.projects.milestones.api import MilestoneWatchersViewSet from taiga.projects.userstories.api import UserStoryViewSet from taiga.projects.userstories.api import UserStoryVotersViewSet +from taiga.projects.userstories.api import UserStoryWatchersViewSet from taiga.projects.tasks.api import TaskViewSet from taiga.projects.tasks.api import TaskVotersViewSet +from taiga.projects.tasks.api import TaskWatchersViewSet from taiga.projects.issues.api import IssueViewSet from taiga.projects.issues.api import IssueVotersViewSet -from taiga.projects.wiki.api import WikiViewSet, WikiLinkViewSet +from taiga.projects.issues.api import IssueWatchersViewSet +from taiga.projects.wiki.api import WikiViewSet +from taiga.projects.wiki.api import WikiLinkViewSet +from taiga.projects.wiki.api import WikiWatchersViewSet router.register(r"milestones", MilestoneViewSet, base_name="milestones") +router.register(r"milestones/(?P\d+)/watchers", MilestoneWatchersViewSet, base_name="milestone-watchers") router.register(r"userstories", UserStoryViewSet, base_name="userstories") router.register(r"userstories/(?P\d+)/voters", UserStoryVotersViewSet, base_name="userstory-voters") +router.register(r"userstories/(?P\d+)/watchers", UserStoryWatchersViewSet, base_name="userstory-watchers") router.register(r"tasks", TaskViewSet, base_name="tasks") router.register(r"tasks/(?P\d+)/voters", TaskVotersViewSet, base_name="task-voters") +router.register(r"tasks/(?P\d+)/watchers", TaskWatchersViewSet, base_name="task-watchers") router.register(r"issues", IssueViewSet, base_name="issues") router.register(r"issues/(?P\d+)/voters", IssueVotersViewSet, base_name="issue-voters") +router.register(r"issues/(?P\d+)/watchers", IssueWatchersViewSet, base_name="issue-watchers") router.register(r"wiki", WikiViewSet, base_name="wiki") +router.register(r"wiki/(?P\d+)/watchers", WikiWatchersViewSet, base_name="wiki-watchers") router.register(r"wiki-links", WikiLinkViewSet, base_name="wiki-links") diff --git a/taiga/timeline/signals.py b/taiga/timeline/signals.py index d56db4ac..5673fbbb 100644 --- a/taiga/timeline/signals.py +++ b/taiga/timeline/signals.py @@ -22,14 +22,12 @@ from taiga.projects.history import services as history_services from taiga.projects.models import Project from taiga.users.models import User from taiga.projects.history.choices import HistoryType +from taiga.projects.notifications import services as notifications_services from taiga.timeline.service import (push_to_timeline, build_user_namespace, build_project_namespace, extract_user_info) -# TODO: Add events to followers timeline when followers are implemented. -# TODO: Add events to project watchers timeline when project watchers are implemented. - def _push_to_timeline(*args, **kwargs): if settings.CELERY_ENABLED: @@ -60,9 +58,9 @@ def _push_to_timelines(project, user, obj, event_type, created_datetime, extra_d related_people |= User.objects.filter(id=obj.assigned_to_id) ## - Watchers - watchers = getattr(obj, "watchers", None) + watchers = notifications_services.get_watchers(obj) if watchers: - related_people |= obj.get_watchers() + related_people |= watchers ## - Exclude inactive and system users and remove duplicate related_people = related_people.exclude(is_active=False) diff --git a/tests/factories.py b/tests/factories.py index 8a351d49..a0950733 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -441,6 +441,17 @@ class VotesFactory(Factory): object_id = factory.Sequence(lambda n: n) +class WatchedFactory(Factory): + class Meta: + model = "notifications.Watched" + strategy = factory.CREATE_STRATEGY + + content_type = factory.SubFactory("tests.factories.ContentTypeFactory") + object_id = factory.Sequence(lambda n: n) + user = factory.SubFactory("tests.factories.UserFactory") + project = factory.SubFactory("tests.factories.ProjectFactory") + + class ContentTypeFactory(Factory): class Meta: model = "contenttypes.ContentType" diff --git a/tests/integration/resources_permissions/test_issues_resources.py b/tests/integration/resources_permissions/test_issues_resources.py index d9950e00..469efacc 100644 --- a/tests/integration/resources_permissions/test_issues_resources.py +++ b/tests/integration/resources_permissions/test_issues_resources.py @@ -9,6 +9,7 @@ from taiga.base.utils import json from tests import factories as f from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals from taiga.projects.votes.services import add_vote +from taiga.projects.notifications.services import add_watcher from taiga.projects.occ import OCCResourceMixin from unittest import mock @@ -616,3 +617,51 @@ def test_issue_action_unwatch(client, data): assert results == [401, 200, 200, 200, 200] results = helper_test_http_method(client, 'post', private_url2, "", users) assert results == [404, 404, 404, 200, 200] + + +def test_issue_watchers_list(client, data): + public_url = reverse('issue-watchers-list', kwargs={"resource_id": data.public_issue.pk}) + private_url1 = reverse('issue-watchers-list', kwargs={"resource_id": data.private_issue1.pk}) + private_url2 = reverse('issue-watchers-list', kwargs={"resource_id": data.private_issue2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_issue_watchers_retrieve(client, data): + add_watcher(data.public_issue, data.project_owner) + public_url = reverse('issue-watchers-detail', kwargs={"resource_id": data.public_issue.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_issue1, data.project_owner) + private_url1 = reverse('issue-watchers-detail', kwargs={"resource_id": data.private_issue1.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_issue2, data.project_owner) + private_url2 = reverse('issue-watchers-detail', kwargs={"resource_id": data.private_issue2.pk, + "pk": data.project_owner.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] diff --git a/tests/integration/resources_permissions/test_milestones_resources.py b/tests/integration/resources_permissions/test_milestones_resources.py index 955754f9..40a8c008 100644 --- a/tests/integration/resources_permissions/test_milestones_resources.py +++ b/tests/integration/resources_permissions/test_milestones_resources.py @@ -3,6 +3,7 @@ from django.core.urlresolvers import reverse from taiga.base.utils import json from taiga.projects.milestones.serializers import MilestoneSerializer from taiga.projects.milestones.models import Milestone +from taiga.projects.notifications.services import add_watcher from taiga.permissions.permissions import MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_PERMISSIONS from tests import factories as f @@ -274,3 +275,93 @@ def test_milestone_action_stats(client, data): results = helper_test_http_method(client, 'get', private_url2, None, users) assert results == [401, 403, 403, 200, 200] + + +def test_milestone_action_watch(client, data): + public_url = reverse('milestones-watch', kwargs={"pk": data.public_milestone.pk}) + private_url1 = reverse('milestones-watch', kwargs={"pk": data.private_milestone1.pk}) + private_url2 = reverse('milestones-watch', kwargs={"pk": data.private_milestone2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [404, 404, 404, 200, 200] + + +def test_milestone_action_unwatch(client, data): + public_url = reverse('milestones-unwatch', kwargs={"pk": data.public_milestone.pk}) + private_url1 = reverse('milestones-unwatch', kwargs={"pk": data.private_milestone1.pk}) + private_url2 = reverse('milestones-unwatch', kwargs={"pk": data.private_milestone2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [404, 404, 404, 200, 200] + + +def test_milestone_watchers_list(client, data): + public_url = reverse('milestone-watchers-list', kwargs={"resource_id": data.public_milestone.pk}) + private_url1 = reverse('milestone-watchers-list', kwargs={"resource_id": data.private_milestone1.pk}) + private_url2 = reverse('milestone-watchers-list', kwargs={"resource_id": data.private_milestone2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_milestone_watchers_retrieve(client, data): + add_watcher(data.public_milestone, data.project_owner) + public_url = reverse('milestone-watchers-detail', kwargs={"resource_id": data.public_milestone.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_milestone1, data.project_owner) + private_url1 = reverse('milestone-watchers-detail', kwargs={"resource_id": data.private_milestone1.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_milestone2, data.project_owner) + private_url2 = reverse('milestone-watchers-detail', kwargs={"resource_id": data.private_milestone2.pk, + "pk": data.project_owner.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] diff --git a/tests/integration/resources_permissions/test_projects_resource.py b/tests/integration/resources_permissions/test_projects_resource.py index 888b1ef4..27c08d1f 100644 --- a/tests/integration/resources_permissions/test_projects_resource.py +++ b/tests/integration/resources_permissions/test_projects_resource.py @@ -81,6 +81,13 @@ def data(): 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.WatchedFactory(content_type=project_ct, object_id=m.public_project.pk, user=m.project_member_with_perms) + f.WatchedFactory(content_type=project_ct, object_id=m.public_project.pk, user=m.project_owner) + f.WatchedFactory(content_type=project_ct, object_id=m.private_project1.pk, user=m.project_member_with_perms) + f.WatchedFactory(content_type=project_ct, object_id=m.private_project1.pk, user=m.project_owner) + f.WatchedFactory(content_type=project_ct, object_id=m.private_project2.pk, user=m.project_member_with_perms) + f.WatchedFactory(content_type=project_ct, object_id=m.private_project2.pk, user=m.project_owner) + return m @@ -109,6 +116,7 @@ def test_project_update(client, data): project_data = ProjectDetailSerializer(data.private_project2).data project_data["is_private"] = False + project_data = json.dumps(project_data) users = [ @@ -300,6 +308,51 @@ def test_project_fans_retrieve(client, data): assert results == [401, 403, 403, 200, 200] +def test_project_watchers_list(client, data): + public_url = reverse('project-watchers-list', kwargs={"resource_id": data.public_project.pk}) + private1_url = reverse('project-watchers-list', kwargs={"resource_id": data.private_project1.pk}) + private2_url = reverse('project-watchers-list', kwargs={"resource_id": data.private_project2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method_and_count(client, 'get', public_url, None, users) + assert results == [(200, 2), (200, 2), (200, 2), (200, 2), (200, 2)] + results = helper_test_http_method_and_count(client, 'get', private1_url, None, users) + assert results == [(200, 2), (200, 2), (200, 2), (200, 2), (200, 2)] + results = helper_test_http_method_and_count(client, 'get', private2_url, None, users) + assert results == [(401, 0), (403, 0), (403, 0), (200, 2), (200, 2)] + + +def test_project_watchers_retrieve(client, data): + public_url = reverse('project-watchers-detail', kwargs={"resource_id": data.public_project.pk, + "pk": data.project_owner.pk}) + private1_url = reverse('project-watchers-detail', kwargs={"resource_id": data.private_project1.pk, + "pk": data.project_owner.pk}) + private2_url = reverse('project-watchers-detail', kwargs={"resource_id": data.private_project2.pk, + "pk": data.project_owner.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + + def test_project_action_create_template(client, data): public_url = reverse('projects-create-template', kwargs={"pk": data.public_project.pk}) private1_url = reverse('projects-create-template', kwargs={"pk": data.private_project1.pk}) diff --git a/tests/integration/resources_permissions/test_tasks_resources.py b/tests/integration/resources_permissions/test_tasks_resources.py index bd4d0e09..4a871e8e 100644 --- a/tests/integration/resources_permissions/test_tasks_resources.py +++ b/tests/integration/resources_permissions/test_tasks_resources.py @@ -10,6 +10,7 @@ from taiga.projects.occ import OCCResourceMixin from tests import factories as f from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals from taiga.projects.votes.services import add_vote +from taiga.projects.notifications.services import add_watcher from unittest import mock @@ -571,3 +572,51 @@ def test_task_action_unwatch(client, data): assert results == [401, 200, 200, 200, 200] results = helper_test_http_method(client, 'post', private_url2, "", users) assert results == [404, 404, 404, 200, 200] + + +def test_task_watchers_list(client, data): + public_url = reverse('task-watchers-list', kwargs={"resource_id": data.public_task.pk}) + private_url1 = reverse('task-watchers-list', kwargs={"resource_id": data.private_task1.pk}) + private_url2 = reverse('task-watchers-list', kwargs={"resource_id": data.private_task2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_task_watchers_retrieve(client, data): + add_watcher(data.public_task, data.project_owner) + public_url = reverse('task-watchers-detail', kwargs={"resource_id": data.public_task.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_task1, data.project_owner) + private_url1 = reverse('task-watchers-detail', kwargs={"resource_id": data.private_task1.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_task2, data.project_owner) + private_url2 = reverse('task-watchers-detail', kwargs={"resource_id": data.private_task2.pk, + "pk": data.project_owner.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] diff --git a/tests/integration/resources_permissions/test_userstories_resources.py b/tests/integration/resources_permissions/test_userstories_resources.py index 219c4e2a..20881aed 100644 --- a/tests/integration/resources_permissions/test_userstories_resources.py +++ b/tests/integration/resources_permissions/test_userstories_resources.py @@ -10,6 +10,7 @@ from taiga.projects.occ import OCCResourceMixin from tests import factories as f from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals from taiga.projects.votes.services import add_vote +from taiga.projects.notifications.services import add_watcher from unittest import mock @@ -570,3 +571,51 @@ def test_user_story_action_unwatch(client, data): assert results == [401, 200, 200, 200, 200] results = helper_test_http_method(client, 'post', private_url2, "", users) assert results == [404, 404, 404, 200, 200] + + +def test_userstory_watchers_list(client, data): + public_url = reverse('userstory-watchers-list', kwargs={"resource_id": data.public_user_story.pk}) + private_url1 = reverse('userstory-watchers-list', kwargs={"resource_id": data.private_user_story1.pk}) + private_url2 = reverse('userstory-watchers-list', kwargs={"resource_id": data.private_user_story2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_userstory_watchers_retrieve(client, data): + add_watcher(data.public_user_story, data.project_owner) + public_url = reverse('userstory-watchers-detail', kwargs={"resource_id": data.public_user_story.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_user_story1, data.project_owner) + private_url1 = reverse('userstory-watchers-detail', kwargs={"resource_id": data.private_user_story1.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_user_story2, data.project_owner) + private_url2 = reverse('userstory-watchers-detail', kwargs={"resource_id": data.private_user_story2.pk, + "pk": data.project_owner.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] diff --git a/tests/integration/resources_permissions/test_wiki_resources.py b/tests/integration/resources_permissions/test_wiki_resources.py index cf6089b7..14f2f92b 100644 --- a/tests/integration/resources_permissions/test_wiki_resources.py +++ b/tests/integration/resources_permissions/test_wiki_resources.py @@ -1,10 +1,11 @@ from django.core.urlresolvers import reverse from taiga.base.utils import json +from taiga.permissions.permissions import MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_PERMISSIONS +from taiga.projects.notifications.services import add_watcher +from taiga.projects.occ import OCCResourceMixin from taiga.projects.wiki.serializers import WikiPageSerializer, WikiLinkSerializer from taiga.projects.wiki.models import WikiPage, WikiLink -from taiga.permissions.permissions import MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_PERMISSIONS -from taiga.projects.occ import OCCResourceMixin from tests import factories as f from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals @@ -436,3 +437,93 @@ def test_wiki_link_patch(client, data): patch_data = json.dumps({"title": "test"}) results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) assert results == [401, 403, 403, 200, 200] + + +def test_wikipage_action_watch(client, data): + public_url = reverse('wiki-watch', kwargs={"pk": data.public_wiki_page.pk}) + private_url1 = reverse('wiki-watch', kwargs={"pk": data.private_wiki_page1.pk}) + private_url2 = reverse('wiki-watch', kwargs={"pk": data.private_wiki_page2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [404, 404, 404, 200, 200] + + +def test_wikipage_action_unwatch(client, data): + public_url = reverse('wiki-unwatch', kwargs={"pk": data.public_wiki_page.pk}) + private_url1 = reverse('wiki-unwatch', kwargs={"pk": data.private_wiki_page1.pk}) + private_url2 = reverse('wiki-unwatch', kwargs={"pk": data.private_wiki_page2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', public_url, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url1, "", users) + assert results == [401, 200, 200, 200, 200] + results = helper_test_http_method(client, 'post', private_url2, "", users) + assert results == [404, 404, 404, 200, 200] + + +def test_wikipage_watchers_list(client, data): + public_url = reverse('wiki-watchers-list', kwargs={"resource_id": data.public_wiki_page.pk}) + private_url1 = reverse('wiki-watchers-list', kwargs={"resource_id": data.private_wiki_page1.pk}) + private_url2 = reverse('wiki-watchers-list', kwargs={"resource_id": data.private_wiki_page2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_wikipage_watchers_retrieve(client, data): + add_watcher(data.public_wiki_page, data.project_owner) + public_url = reverse('wiki-watchers-detail', kwargs={"resource_id": data.public_wiki_page.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_wiki_page1, data.project_owner) + private_url1 = reverse('wiki-watchers-detail', kwargs={"resource_id": data.private_wiki_page1.pk, + "pk": data.project_owner.pk}) + add_watcher(data.private_wiki_page2, data.project_owner) + private_url2 = reverse('wiki-watchers-detail', kwargs={"resource_id": data.private_wiki_page2.pk, + "pk": data.project_owner.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] diff --git a/tests/integration/test_issues.py b/tests/integration/test_issues.py index 84727427..54722c93 100644 --- a/tests/integration/test_issues.py +++ b/tests/integration/test_issues.py @@ -412,6 +412,6 @@ def test_custom_fields_csv_generation(): data.seek(0) reader = csv.reader(data) row = next(reader) - assert row[16] == attr.name + assert row[18] == attr.name row = next(reader) - assert row[16] == "val1" + assert row[18] == "val1" diff --git a/tests/integration/test_notifications.py b/tests/integration/test_notifications.py index ab0fdb42..87ea400d 100644 --- a/tests/integration/test_notifications.py +++ b/tests/integration/test_notifications.py @@ -211,6 +211,124 @@ def test_users_to_notify(): assert users == {member1.user, issue.get_owner()} +def test_watching_users_to_notify_on_issue_modification_1(): + # If: + # - the user is watching the issue + # - the user is not watching the project + # - the notify policy is watch + # Then: + # - email is sent + project = f.ProjectFactory.create(anon_permissions= ["view_issues"]) + issue = f.IssueFactory.create(project=project) + watching_user = f.UserFactory() + issue.add_watcher(watching_user) + watching_user_policy = services.get_notify_policy(project, watching_user) + issue.description = "test1" + issue.save() + watching_user_policy.notify_level = NotifyLevel.watch + users = services.get_users_to_notify(issue) + assert users == {watching_user, issue.owner} + + +def test_watching_users_to_notify_on_issue_modification_2(): + # If: + # - the user is watching the issue + # - the user is not watching the project + # - the notify policy is notwatch + # Then: + # - email is sent + project = f.ProjectFactory.create(anon_permissions= ["view_issues"]) + issue = f.IssueFactory.create(project=project) + watching_user = f.UserFactory() + issue.add_watcher(watching_user) + watching_user_policy = services.get_notify_policy(project, watching_user) + issue.description = "test1" + issue.save() + watching_user_policy.notify_level = NotifyLevel.notwatch + users = services.get_users_to_notify(issue) + assert users == {watching_user, issue.owner} + + +def test_watching_users_to_notify_on_issue_modification_3(): + # If: + # - the user is watching the issue + # - the user is not watching the project + # - the notify policy is ignore + # Then: + # - email is not sent + project = f.ProjectFactory.create(anon_permissions= ["view_issues"]) + issue = f.IssueFactory.create(project=project) + watching_user = f.UserFactory() + issue.add_watcher(watching_user) + watching_user_policy = services.get_notify_policy(project, watching_user) + issue.description = "test1" + issue.save() + watching_user_policy.notify_level = NotifyLevel.ignore + watching_user_policy.save() + users = services.get_users_to_notify(issue) + assert users == {issue.owner} + + +def test_watching_users_to_notify_on_issue_modification_4(): + # If: + # - the user is not watching the issue + # - the user is watching the project + # - the notify policy is ignore + # Then: + # - email is not sent + project = f.ProjectFactory.create(anon_permissions= ["view_issues"]) + issue = f.IssueFactory.create(project=project) + watching_user = f.UserFactory() + project.add_watcher(watching_user) + watching_user_policy = services.get_notify_policy(project, watching_user) + issue.description = "test1" + issue.save() + watching_user_policy.notify_level = NotifyLevel.ignore + watching_user_policy.save() + users = services.get_users_to_notify(issue) + assert users == {issue.owner} + + +def test_watching_users_to_notify_on_issue_modification_5(): + # If: + # - the user is not watching the issue + # - the user is watching the project + # - the notify policy is watch + # Then: + # - email is sent + project = f.ProjectFactory.create(anon_permissions= ["view_issues"]) + issue = f.IssueFactory.create(project=project) + watching_user = f.UserFactory() + project.add_watcher(watching_user) + watching_user_policy = services.get_notify_policy(project, watching_user) + issue.description = "test1" + issue.save() + watching_user_policy.notify_level = NotifyLevel.watch + watching_user_policy.save() + users = services.get_users_to_notify(issue) + assert users == {watching_user, issue.owner} + + +def test_watching_users_to_notify_on_issue_modification_6(): + # If: + # - the user is not watching the issue + # - the user is watching the project + # - the notify policy is notwatch + # Then: + # - email is sent + project = f.ProjectFactory.create(anon_permissions= ["view_issues"]) + issue = f.IssueFactory.create(project=project) + watching_user = f.UserFactory() + project.add_watcher(watching_user) + watching_user_policy = services.get_notify_policy(project, watching_user) + issue.description = "test1" + issue.save() + watching_user_policy.notify_level = NotifyLevel.notwatch + watching_user_policy.save() + users = services.get_users_to_notify(issue) + assert users == {watching_user, issue.owner} + + def test_send_notifications_using_services_method(settings, mail): settings.CHANGE_NOTIFICATIONS_MIN_INTERVAL = 1 diff --git a/tests/integration/test_tasks.py b/tests/integration/test_tasks.py index 382bfc6f..08825955 100644 --- a/tests/integration/test_tasks.py +++ b/tests/integration/test_tasks.py @@ -163,6 +163,6 @@ def test_custom_fields_csv_generation(): data.seek(0) reader = csv.reader(data) row = next(reader) - assert row[17] == attr.name + assert row[19] == attr.name row = next(reader) - assert row[17] == "val1" + assert row[19] == "val1" diff --git a/tests/integration/test_timeline.py b/tests/integration/test_timeline.py index 00e61fc3..4620e881 100644 --- a/tests/integration/test_timeline.py +++ b/tests/integration/test_timeline.py @@ -196,8 +196,10 @@ def test_create_membership_timeline(): def test_update_project_timeline(): + user_watcher= factories.UserFactory() project = factories.ProjectFactory.create(name="test project timeline") history_services.take_snapshot(project, user=project.owner) + project.add_watcher(user_watcher) project.name = "test project timeline updated" project.save() history_services.take_snapshot(project, user=project.owner) @@ -206,11 +208,18 @@ def test_update_project_timeline(): assert project_timeline[0].data["project"]["name"] == "test project timeline updated" assert project_timeline[0].data["values_diff"]["name"][0] == "test project timeline" assert project_timeline[0].data["values_diff"]["name"][1] == "test project timeline updated" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "projects.project.change" + assert user_watcher_timeline[0].data["project"]["name"] == "test project timeline updated" + assert user_watcher_timeline[0].data["values_diff"]["name"][0] == "test project timeline" + assert user_watcher_timeline[0].data["values_diff"]["name"][1] == "test project timeline updated" def test_update_milestone_timeline(): + user_watcher= factories.UserFactory() milestone = factories.MilestoneFactory.create(name="test milestone timeline") history_services.take_snapshot(milestone, user=milestone.owner) + milestone.add_watcher(user_watcher) milestone.name = "test milestone timeline updated" milestone.save() history_services.take_snapshot(milestone, user=milestone.owner) @@ -219,11 +228,18 @@ def test_update_milestone_timeline(): assert project_timeline[0].data["milestone"]["name"] == "test milestone timeline updated" assert project_timeline[0].data["values_diff"]["name"][0] == "test milestone timeline" assert project_timeline[0].data["values_diff"]["name"][1] == "test milestone timeline updated" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "milestones.milestone.change" + assert user_watcher_timeline[0].data["milestone"]["name"] == "test milestone timeline updated" + assert user_watcher_timeline[0].data["values_diff"]["name"][0] == "test milestone timeline" + assert user_watcher_timeline[0].data["values_diff"]["name"][1] == "test milestone timeline updated" def test_update_user_story_timeline(): + user_watcher= factories.UserFactory() user_story = factories.UserStoryFactory.create(subject="test us timeline") history_services.take_snapshot(user_story, user=user_story.owner) + user_story.add_watcher(user_watcher) user_story.subject = "test us timeline updated" user_story.save() history_services.take_snapshot(user_story, user=user_story.owner) @@ -232,11 +248,18 @@ def test_update_user_story_timeline(): assert project_timeline[0].data["userstory"]["subject"] == "test us timeline updated" assert project_timeline[0].data["values_diff"]["subject"][0] == "test us timeline" assert project_timeline[0].data["values_diff"]["subject"][1] == "test us timeline updated" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "userstories.userstory.change" + assert user_watcher_timeline[0].data["userstory"]["subject"] == "test us timeline updated" + assert user_watcher_timeline[0].data["values_diff"]["subject"][0] == "test us timeline" + assert user_watcher_timeline[0].data["values_diff"]["subject"][1] == "test us timeline updated" def test_update_issue_timeline(): + user_watcher= factories.UserFactory() issue = factories.IssueFactory.create(subject="test issue timeline") history_services.take_snapshot(issue, user=issue.owner) + issue.add_watcher(user_watcher) issue.subject = "test issue timeline updated" issue.save() history_services.take_snapshot(issue, user=issue.owner) @@ -245,11 +268,18 @@ def test_update_issue_timeline(): assert project_timeline[0].data["issue"]["subject"] == "test issue timeline updated" assert project_timeline[0].data["values_diff"]["subject"][0] == "test issue timeline" assert project_timeline[0].data["values_diff"]["subject"][1] == "test issue timeline updated" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "issues.issue.change" + assert user_watcher_timeline[0].data["issue"]["subject"] == "test issue timeline updated" + assert user_watcher_timeline[0].data["values_diff"]["subject"][0] == "test issue timeline" + assert user_watcher_timeline[0].data["values_diff"]["subject"][1] == "test issue timeline updated" def test_update_task_timeline(): + user_watcher= factories.UserFactory() task = factories.TaskFactory.create(subject="test task timeline") history_services.take_snapshot(task, user=task.owner) + task.add_watcher(user_watcher) task.subject = "test task timeline updated" task.save() history_services.take_snapshot(task, user=task.owner) @@ -258,11 +288,18 @@ def test_update_task_timeline(): assert project_timeline[0].data["task"]["subject"] == "test task timeline updated" assert project_timeline[0].data["values_diff"]["subject"][0] == "test task timeline" assert project_timeline[0].data["values_diff"]["subject"][1] == "test task timeline updated" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "tasks.task.change" + assert user_watcher_timeline[0].data["task"]["subject"] == "test task timeline updated" + assert user_watcher_timeline[0].data["values_diff"]["subject"][0] == "test task timeline" + assert user_watcher_timeline[0].data["values_diff"]["subject"][1] == "test task timeline updated" def test_update_wiki_page_timeline(): + user_watcher= factories.UserFactory() page = factories.WikiPageFactory.create(slug="test wiki page timeline") history_services.take_snapshot(page, user=page.owner) + page.add_watcher(user_watcher) page.slug = "test wiki page timeline updated" page.save() history_services.take_snapshot(page, user=page.owner) @@ -271,6 +308,11 @@ def test_update_wiki_page_timeline(): assert project_timeline[0].data["wikipage"]["slug"] == "test wiki page timeline updated" assert project_timeline[0].data["values_diff"]["slug"][0] == "test wiki page timeline" assert project_timeline[0].data["values_diff"]["slug"][1] == "test wiki page timeline updated" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "wiki.wikipage.change" + assert user_watcher_timeline[0].data["wikipage"]["slug"] == "test wiki page timeline updated" + assert user_watcher_timeline[0].data["values_diff"]["slug"][0] == "test wiki page timeline" + assert user_watcher_timeline[0].data["values_diff"]["slug"][1] == "test wiki page timeline updated" def test_update_membership_timeline(): @@ -298,50 +340,80 @@ def test_update_membership_timeline(): def test_delete_project_timeline(): project = factories.ProjectFactory.create(name="test project timeline") + user_watcher= factories.UserFactory() + project.add_watcher(user_watcher) history_services.take_snapshot(project, user=project.owner, delete=True) user_timeline = service.get_project_timeline(project) assert user_timeline[0].event_type == "projects.project.delete" assert user_timeline[0].data["project"]["id"] == project.id + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "projects.project.delete" + assert user_watcher_timeline[0].data["project"]["id"] == project.id def test_delete_milestone_timeline(): milestone = factories.MilestoneFactory.create(name="test milestone timeline") + user_watcher= factories.UserFactory() + milestone.add_watcher(user_watcher) history_services.take_snapshot(milestone, user=milestone.owner, delete=True) project_timeline = service.get_project_timeline(milestone.project) assert project_timeline[0].event_type == "milestones.milestone.delete" assert project_timeline[0].data["milestone"]["name"] == "test milestone timeline" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "milestones.milestone.delete" + assert user_watcher_timeline[0].data["milestone"]["name"] == "test milestone timeline" def test_delete_user_story_timeline(): user_story = factories.UserStoryFactory.create(subject="test us timeline") + user_watcher= factories.UserFactory() + user_story.add_watcher(user_watcher) history_services.take_snapshot(user_story, user=user_story.owner, delete=True) project_timeline = service.get_project_timeline(user_story.project) assert project_timeline[0].event_type == "userstories.userstory.delete" assert project_timeline[0].data["userstory"]["subject"] == "test us timeline" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "userstories.userstory.delete" + assert user_watcher_timeline[0].data["userstory"]["subject"] == "test us timeline" def test_delete_issue_timeline(): issue = factories.IssueFactory.create(subject="test issue timeline") + user_watcher= factories.UserFactory() + issue.add_watcher(user_watcher) history_services.take_snapshot(issue, user=issue.owner, delete=True) project_timeline = service.get_project_timeline(issue.project) assert project_timeline[0].event_type == "issues.issue.delete" assert project_timeline[0].data["issue"]["subject"] == "test issue timeline" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "issues.issue.delete" + assert user_watcher_timeline[0].data["issue"]["subject"] == "test issue timeline" def test_delete_task_timeline(): task = factories.TaskFactory.create(subject="test task timeline") + user_watcher= factories.UserFactory() + task.add_watcher(user_watcher) history_services.take_snapshot(task, user=task.owner, delete=True) project_timeline = service.get_project_timeline(task.project) assert project_timeline[0].event_type == "tasks.task.delete" assert project_timeline[0].data["task"]["subject"] == "test task timeline" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "tasks.task.delete" + assert user_watcher_timeline[0].data["task"]["subject"] == "test task timeline" def test_delete_wiki_page_timeline(): page = factories.WikiPageFactory.create(slug="test wiki page timeline") + user_watcher= factories.UserFactory() + page.add_watcher(user_watcher) history_services.take_snapshot(page, user=page.owner, delete=True) project_timeline = service.get_project_timeline(page.project) assert project_timeline[0].event_type == "wiki.wikipage.delete" assert project_timeline[0].data["wikipage"]["slug"] == "test wiki page timeline" + user_watcher_timeline = service.get_profile_timeline(user_watcher) + assert user_watcher_timeline[0].event_type == "wiki.wikipage.delete" + assert user_watcher_timeline[0].data["wikipage"]["slug"] == "test wiki page timeline" def test_delete_membership_timeline(): diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py index d8972da2..6b49568a 100644 --- a/tests/integration/test_userstories.py +++ b/tests/integration/test_userstories.py @@ -483,6 +483,6 @@ def test_custom_fields_csv_generation(): data.seek(0) reader = csv.reader(data) row = next(reader) - assert row[24] == attr.name + assert row[26] == attr.name row = next(reader) - assert row[24] == "val1" + assert row[26] == "val1" diff --git a/tests/integration/test_watch_issues.py b/tests/integration/test_watch_issues.py index f51d5075..09ba4f7b 100644 --- a/tests/integration/test_watch_issues.py +++ b/tests/integration/test_watch_issues.py @@ -16,6 +16,7 @@ # along with this program. If not, see . import pytest +import json from django.core.urlresolvers import reverse from .. import factories as f @@ -45,3 +46,78 @@ def test_unwatch_issue(client): response = client.post(url) assert response.status_code == 200 + + +def test_list_issue_watchers(client): + user = f.UserFactory.create() + issue = f.IssueFactory(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_owner=True) + f.WatchedFactory.create(content_object=issue, user=user) + url = reverse("issue-watchers-list", args=(issue.id,)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data[0]['id'] == user.id + + +def test_get_issue_watcher(client): + user = f.UserFactory.create() + issue = f.IssueFactory(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_owner=True) + watch = f.WatchedFactory.create(content_object=issue, user=user) + url = reverse("issue-watchers-detail", args=(issue.id, watch.user.id)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['id'] == watch.user.id + + +def test_get_issue_watchers(client): + user = f.UserFactory.create() + issue = f.IssueFactory(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_owner=True) + url = reverse("issues-detail", args=(issue.id,)) + + f.WatchedFactory.create(content_object=issue, user=user) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['watchers'] == [user.id] + + +def test_get_issue_is_watched(client): + user = f.UserFactory.create() + issue = f.IssueFactory(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_owner=True) + url_detail = reverse("issues-detail", args=(issue.id,)) + url_watch = reverse("issues-watch", args=(issue.id,)) + url_unwatch = reverse("issues-unwatch", args=(issue.id,)) + + client.login(user) + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [] + assert response.data['is_watched'] == False + + response = client.post(url_watch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [user.id] + assert response.data['is_watched'] == True + + response = client.post(url_unwatch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [] + assert response.data['is_watched'] == False diff --git a/tests/integration/test_watch_projects.py b/tests/integration/test_watch_projects.py index 8bb765ce..358c15f2 100644 --- a/tests/integration/test_watch_projects.py +++ b/tests/integration/test_watch_projects.py @@ -16,6 +16,7 @@ # along with this program. If not, see . import pytest +import json from django.core.urlresolvers import reverse from .. import factories as f @@ -45,3 +46,78 @@ def test_unwacth_project(client): response = client.post(url) assert response.status_code == 200 + + +def test_list_project_watchers(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_owner=True) + f.WatchedFactory.create(content_object=project, user=user) + url = reverse("project-watchers-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_watcher(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_owner=True) + watch = f.WatchedFactory.create(content_object=project, user=user) + url = reverse("project-watchers-detail", args=(project.id, watch.user.id)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['id'] == watch.user.id + + +def test_get_project_watchers(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.WatchedFactory.create(content_object=project, user=user) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['watchers'] == [user.id] + + +def test_get_project_is_watched(client): + user = f.UserFactory.create() + project = f.create_project(owner=user) + f.MembershipFactory.create(project=project, user=user, is_owner=True) + url_detail = reverse("projects-detail", args=(project.id,)) + url_watch = reverse("projects-watch", args=(project.id,)) + url_unwatch = reverse("projects-unwatch", args=(project.id,)) + + client.login(user) + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [] + assert response.data['is_watched'] == False + + response = client.post(url_watch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [user.id] + assert response.data['is_watched'] == True + + response = client.post(url_unwatch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [] + assert response.data['is_watched'] == False diff --git a/tests/integration/test_watch_tasks.py b/tests/integration/test_watch_tasks.py index f62e4c7b..7444a948 100644 --- a/tests/integration/test_watch_tasks.py +++ b/tests/integration/test_watch_tasks.py @@ -16,6 +16,7 @@ # along with this program. If not, see . import pytest +import json from django.core.urlresolvers import reverse from .. import factories as f @@ -45,3 +46,78 @@ def test_unwatch_task(client): response = client.post(url) assert response.status_code == 200 + + +def test_list_task_watchers(client): + user = f.UserFactory.create() + task = f.TaskFactory(owner=user) + f.MembershipFactory.create(project=task.project, user=user, is_owner=True) + f.WatchedFactory.create(content_object=task, user=user) + url = reverse("task-watchers-list", args=(task.id,)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data[0]['id'] == user.id + + +def test_get_task_watcher(client): + user = f.UserFactory.create() + task = f.TaskFactory(owner=user) + f.MembershipFactory.create(project=task.project, user=user, is_owner=True) + watch = f.WatchedFactory.create(content_object=task, user=user) + url = reverse("task-watchers-detail", args=(task.id, watch.user.id)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['id'] == watch.user.id + + +def test_get_task_watchers(client): + user = f.UserFactory.create() + task = f.TaskFactory(owner=user) + f.MembershipFactory.create(project=task.project, user=user, is_owner=True) + url = reverse("tasks-detail", args=(task.id,)) + + f.WatchedFactory.create(content_object=task, user=user) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['watchers'] == [user.id] + + +def test_get_task_is_watched(client): + user = f.UserFactory.create() + task = f.TaskFactory(owner=user) + f.MembershipFactory.create(project=task.project, user=user, is_owner=True) + url_detail = reverse("tasks-detail", args=(task.id,)) + url_watch = reverse("tasks-watch", args=(task.id,)) + url_unwatch = reverse("tasks-unwatch", args=(task.id,)) + + client.login(user) + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [] + assert response.data['is_watched'] == False + + response = client.post(url_watch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [user.id] + assert response.data['is_watched'] == True + + response = client.post(url_unwatch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [] + assert response.data['is_watched'] == False diff --git a/tests/integration/test_watch_userstories.py b/tests/integration/test_watch_userstories.py index a6a7123e..cad86151 100644 --- a/tests/integration/test_watch_userstories.py +++ b/tests/integration/test_watch_userstories.py @@ -16,6 +16,7 @@ # along with this program. If not, see . import pytest +import json from django.core.urlresolvers import reverse from .. import factories as f @@ -45,3 +46,78 @@ def test_unwatch_user_story(client): response = client.post(url) assert response.status_code == 200 + + +def test_list_user_story_watchers(client): + user = f.UserFactory.create() + user_story = f.UserStoryFactory(owner=user) + f.MembershipFactory.create(project=user_story.project, user=user, is_owner=True) + f.WatchedFactory.create(content_object=user_story, user=user) + url = reverse("userstory-watchers-list", args=(user_story.id,)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data[0]['id'] == user.id + + +def test_get_user_story_watcher(client): + user = f.UserFactory.create() + user_story = f.UserStoryFactory(owner=user) + f.MembershipFactory.create(project=user_story.project, user=user, is_owner=True) + watch = f.WatchedFactory.create(content_object=user_story, user=user) + url = reverse("userstory-watchers-detail", args=(user_story.id, watch.user.id)) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['id'] == watch.user.id + + +def test_get_user_story_watchers(client): + user = f.UserFactory.create() + user_story = f.UserStoryFactory(owner=user) + f.MembershipFactory.create(project=user_story.project, user=user, is_owner=True) + url = reverse("userstories-detail", args=(user_story.id,)) + + f.WatchedFactory.create(content_object=user_story, user=user) + + client.login(user) + response = client.get(url) + + assert response.status_code == 200 + assert response.data['watchers'] == [user.id] + + +def test_get_user_story_is_watched(client): + user = f.UserFactory.create() + user_story = f.UserStoryFactory(owner=user) + f.MembershipFactory.create(project=user_story.project, user=user, is_owner=True) + url_detail = reverse("userstories-detail", args=(user_story.id,)) + url_watch = reverse("userstories-watch", args=(user_story.id,)) + url_unwatch = reverse("userstories-unwatch", args=(user_story.id,)) + + client.login(user) + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [] + assert response.data['is_watched'] == False + + response = client.post(url_watch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [user.id] + assert response.data['is_watched'] == True + + response = client.post(url_unwatch) + assert response.status_code == 200 + + response = client.get(url_detail) + assert response.status_code == 200 + assert response.data['watchers'] == [] + assert response.data['is_watched'] == False diff --git a/tests/integration/test_webhooks.py b/tests/integration/test_webhooks.py index 0b3b32f0..9f4ecd71 100644 --- a/tests/integration/test_webhooks.py +++ b/tests/integration/test_webhooks.py @@ -90,3 +90,26 @@ def test_new_object_with_two_webhook(settings): with patch('taiga.webhooks.tasks.delete_webhook') as delete_webhook_mock: services.take_snapshot(obj, user=obj.owner, comment="test", delete=True) assert delete_webhook_mock.call_count == 2 + + +def test_send_request_one_webhook(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + + objects = [ + f.IssueFactory.create(project=project), + f.TaskFactory.create(project=project), + f.UserStoryFactory.create(project=project), + f.WikiPageFactory.create(project=project) + ] + + for obj in objects: + with patch('taiga.webhooks.tasks._send_request') as _send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test") + assert _send_request_mock.call_count == 1 + + for obj in objects: + with patch('taiga.webhooks.tasks._send_request') as _send_request_mock: + services.take_snapshot(obj, user=obj.owner, comment="test", delete=True) + assert _send_request_mock.call_count == 1 From 3492b46cc9d19a85b8f9e630a224e51276ea37ef Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 18 Aug 2015 09:23:20 +0200 Subject: [PATCH 4/5] 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 From 3111cf79eb5642b24ed57bbd0b0465f8e7b853ba Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 18 Aug 2015 10:49:05 +0200 Subject: [PATCH 5/5] Improving admin --- taiga/projects/admin.py | 5 ++++- taiga/projects/issues/admin.py | 5 ++++- taiga/projects/milestones/admin.py | 3 +++ taiga/projects/notifications/admin.py | 25 +++++++++++++++++++++++++ taiga/projects/tasks/admin.py | 5 ++++- taiga/projects/userstories/admin.py | 4 +++- taiga/projects/votes/admin.py | 25 +++++++++++++++++++++++++ taiga/projects/votes/models.py | 2 +- taiga/projects/wiki/admin.py | 5 ++++- 9 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 taiga/projects/notifications/admin.py create mode 100644 taiga/projects/votes/admin.py diff --git a/taiga/projects/admin.py b/taiga/projects/admin.py index f3eea353..5dcc9758 100644 --- a/taiga/projects/admin.py +++ b/taiga/projects/admin.py @@ -17,7 +17,10 @@ from django.contrib import admin from taiga.projects.milestones.admin import MilestoneInline +from taiga.projects.notifications.admin import WatchedInline +from taiga.projects.votes.admin import VoteInline from taiga.users.admin import RoleInline + from . import models class MembershipAdmin(admin.ModelAdmin): @@ -35,7 +38,7 @@ class ProjectAdmin(admin.ModelAdmin): list_display = ["name", "owner", "created_date", "total_milestones", "total_story_points"] list_display_links = list_display - inlines = [RoleInline, MembershipInline, MilestoneInline] + inlines = [RoleInline, MembershipInline, MilestoneInline, WatchedInline, VoteInline] def get_object(self, *args, **kwargs): self.obj = super().get_object(*args, **kwargs) diff --git a/taiga/projects/issues/admin.py b/taiga/projects/issues/admin.py index 16da297e..fe891719 100644 --- a/taiga/projects/issues/admin.py +++ b/taiga/projects/issues/admin.py @@ -17,13 +17,16 @@ from django.contrib import admin from taiga.projects.attachments.admin import AttachmentInline +from taiga.projects.notifications.admin import WatchedInline +from taiga.projects.votes.admin import VoteInline + from . import models class IssueAdmin(admin.ModelAdmin): list_display = ["project", "milestone", "ref", "subject",] list_display_links = ["ref", "subject",] - # inlines = [AttachmentInline] + inlines = [WatchedInline, VoteInline] def get_object(self, *args, **kwargs): self.obj = super().get_object(*args, **kwargs) diff --git a/taiga/projects/milestones/admin.py b/taiga/projects/milestones/admin.py index b741a1b2..52919e87 100644 --- a/taiga/projects/milestones/admin.py +++ b/taiga/projects/milestones/admin.py @@ -15,6 +15,8 @@ # along with this program. If not, see . from django.contrib import admin +from taiga.projects.notifications.admin import WatchedInline +from taiga.projects.votes.admin import VoteInline from . import models @@ -30,6 +32,7 @@ class MilestoneAdmin(admin.ModelAdmin): list_display_links = list_display list_filter = ["project"] readonly_fields = ["owner"] + inlines = [WatchedInline, VoteInline] admin.site.register(models.Milestone, MilestoneAdmin) diff --git a/taiga/projects/notifications/admin.py b/taiga/projects/notifications/admin.py new file mode 100644 index 00000000..2dc22d61 --- /dev/null +++ b/taiga/projects/notifications/admin.py @@ -0,0 +1,25 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 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 WatchedInline(GenericTabularInline): + model = models.Watched + extra = 0 diff --git a/taiga/projects/tasks/admin.py b/taiga/projects/tasks/admin.py index 937e70a3..5295e7ca 100644 --- a/taiga/projects/tasks/admin.py +++ b/taiga/projects/tasks/admin.py @@ -17,6 +17,9 @@ from django.contrib import admin from taiga.projects.attachments.admin import AttachmentInline +from taiga.projects.notifications.admin import WatchedInline +from taiga.projects.votes.admin import VoteInline + from . import models @@ -24,7 +27,7 @@ class TaskAdmin(admin.ModelAdmin): list_display = ["project", "milestone", "user_story", "ref", "subject",] list_display_links = ["ref", "subject",] list_filter = ["project"] - # inlines = [AttachmentInline] + inlines = [WatchedInline, VoteInline] def get_object(self, *args, **kwargs): self.obj = super().get_object(*args, **kwargs) diff --git a/taiga/projects/userstories/admin.py b/taiga/projects/userstories/admin.py index cd23dc35..9cb3e667 100644 --- a/taiga/projects/userstories/admin.py +++ b/taiga/projects/userstories/admin.py @@ -17,6 +17,8 @@ from django.contrib import admin from taiga.projects.attachments.admin import AttachmentInline +from taiga.projects.notifications.admin import WatchedInline +from taiga.projects.votes.admin import VoteInline from . import models @@ -41,7 +43,7 @@ class UserStoryAdmin(admin.ModelAdmin): list_display = ["project", "milestone", "ref", "subject",] list_display_links = ["ref", "subject",] list_filter = ["project"] - inlines = [RolePointsInline] + inlines = [RolePointsInline, WatchedInline, VoteInline] def get_object(self, *args, **kwargs): self.obj = super().get_object(*args, **kwargs) diff --git a/taiga/projects/votes/admin.py b/taiga/projects/votes/admin.py new file mode 100644 index 00000000..4e06ae8f --- /dev/null +++ b/taiga/projects/votes/admin.py @@ -0,0 +1,25 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 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 VoteInline(GenericTabularInline): + model = models.Vote + extra = 0 diff --git a/taiga/projects/votes/models.py b/taiga/projects/votes/models.py index 7fcf857a..a1af1ff7 100644 --- a/taiga/projects/votes/models.py +++ b/taiga/projects/votes/models.py @@ -63,4 +63,4 @@ class Vote(models.Model): return None def __str__(self): - return self.user + return self.user.get_full_name() diff --git a/taiga/projects/wiki/admin.py b/taiga/projects/wiki/admin.py index cb846105..ca929fb3 100644 --- a/taiga/projects/wiki/admin.py +++ b/taiga/projects/wiki/admin.py @@ -17,6 +17,9 @@ from django.contrib import admin from taiga.projects.attachments.admin import AttachmentInline +from taiga.projects.notifications.admin import WatchedInline +from taiga.projects.votes.admin import VoteInline + from taiga.projects.wiki.models import WikiPage from . import models @@ -24,7 +27,7 @@ from . import models class WikiPageAdmin(admin.ModelAdmin): list_display = ["project", "slug", "owner"] list_display_links = list_display - # inlines = [AttachmentInline] + inlines = [WatchedInline, VoteInline] admin.site.register(models.WikiPage, WikiPageAdmin)