diff --git a/apps/api/plane/space/views/issue.py b/apps/api/plane/space/views/issue.py index 9e2187466aa..42ed83a00fc 100644 --- a/apps/api/plane/space/views/issue.py +++ b/apps/api/plane/space/views/issue.py @@ -1,773 +1 @@ -# Copyright (c) 2023-present Plane Software, Inc. and contributors -# SPDX-License-Identifier: AGPL-3.0-only -# See the LICENSE file for details. - -# Python imports -import json - -# Django imports -from django.contrib.postgres.aggregates import ArrayAgg -from django.contrib.postgres.fields import ArrayField -from django.db.models.functions import Coalesce, JSONObject -from django.core.serializers.json import DjangoJSONEncoder -from django.utils import timezone -from django.db.models import ( - Exists, - F, - Q, - Prefetch, - UUIDField, - Case, - When, - JSONField, - Value, - OuterRef, - Func, - CharField, - Subquery, -) -from django.db.models.functions import Concat - -# Third Party imports -from rest_framework.response import Response -from rest_framework import status -from rest_framework.permissions import AllowAny, IsAuthenticated - - -# Module imports -from .base import BaseAPIView, BaseViewSet - -# fetch the space app grouper function separately -from plane.space.utils.grouper import ( - issue_group_values, - issue_on_results, - issue_queryset_grouper, -) - - -from plane.utils.order_queryset import order_issue_queryset -from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator -from plane.app.serializers import ( - CommentReactionSerializer, - IssueCommentSerializer, - IssueReactionSerializer, - IssueVoteSerializer, -) -from plane.db.models import ( - Issue, - IssueComment, - IssueLink, - IssueReaction, - ProjectMember, - CommentReaction, - DeployBoard, - IssueVote, - ProjectPublicMember, - FileAsset, - CycleIssue, -) -from plane.bgtasks.issue_activities_task import issue_activity -from plane.utils.issue_filters import issue_filters - - -class ProjectIssuesPublicEndpoint(BaseAPIView): - permission_classes = [AllowAny] - - def get(self, request, anchor): - filters = issue_filters(request.query_params, "GET") - order_by_param = request.GET.get("order_by", "-created_at") - - deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").first() - if not deploy_board: - return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND) - - project_id = deploy_board.entity_identifier - slug = deploy_board.workspace.slug - - issue_queryset = ( - Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id) - .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels", "issue_module__module") - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) - .prefetch_related(Prefetch("votes", queryset=IssueVote.objects.select_related("actor"))) - .annotate( - cycle_id=Subquery( - CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] - ) - ) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=FileAsset.objects.filter( - issue_id=OuterRef("id"), - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - ).distinct() - - issue_queryset = issue_queryset.filter(**filters) - - # Issue queryset - issue_queryset, order_by_param = order_issue_queryset( - issue_queryset=issue_queryset, order_by_param=order_by_param - ) - - # Group by - group_by = request.GET.get("group_by", False) - sub_group_by = request.GET.get("sub_group_by", False) - - # issue queryset - issue_queryset = issue_queryset_grouper(queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by) - - if group_by: - if sub_group_by: - if group_by == sub_group_by: - return Response( - {"error": "Group by and sub group by cannot have same parameters"}, - status=status.HTTP_400_BAD_REQUEST, - ) - else: - return self.paginate( - request=request, - order_by=order_by_param, - queryset=issue_queryset, - on_results=lambda issues: issue_on_results( - group_by=group_by, issues=issues, sub_group_by=sub_group_by - ), - paginator_cls=SubGroupedOffsetPaginator, - group_by_fields=issue_group_values( - field=group_by, - slug=slug, - project_id=project_id, - filters=filters, - ), - sub_group_by_fields=issue_group_values( - field=sub_group_by, - slug=slug, - project_id=project_id, - filters=filters, - ), - group_by_field_name=group_by, - sub_group_by_field_name=sub_group_by, - count_filter=Q( - Q(issue_intake__status=1) - | Q(issue_intake__status=-1) - | Q(issue_intake__status=2) - | Q(issue_intake__isnull=True), - archived_at__isnull=True, - is_draft=False, - ), - ) - else: - # Group paginate - return self.paginate( - request=request, - order_by=order_by_param, - queryset=issue_queryset, - on_results=lambda issues: issue_on_results( - group_by=group_by, issues=issues, sub_group_by=sub_group_by - ), - paginator_cls=GroupedOffsetPaginator, - group_by_fields=issue_group_values( - field=group_by, - slug=slug, - project_id=project_id, - filters=filters, - ), - group_by_field_name=group_by, - count_filter=Q( - Q(issue_intake__status=1) - | Q(issue_intake__status=-1) - | Q(issue_intake__status=2) - | Q(issue_intake__isnull=True), - archived_at__isnull=True, - is_draft=False, - ), - ) - else: - return self.paginate( - order_by=order_by_param, - request=request, - queryset=issue_queryset, - on_results=lambda issues: issue_on_results(group_by=group_by, issues=issues, sub_group_by=sub_group_by), - ) - - -class IssueCommentPublicViewSet(BaseViewSet): - serializer_class = IssueCommentSerializer - model = IssueComment - - filterset_fields = ["issue__id", "workspace__id"] - - def get_permissions(self): - if self.action in ["list", "retrieve"]: - self.permission_classes = [AllowAny] - else: - self.permission_classes = [IsAuthenticated] - - return super(IssueCommentPublicViewSet, self).get_permissions() - - def get_queryset(self): - try: - project_deploy_board = DeployBoard.objects.get(anchor=self.kwargs.get("anchor"), entity_name="project") - if project_deploy_board.is_comments_enabled: - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace_id=project_deploy_board.workspace_id) - .filter(issue_id=self.kwargs.get("issue_id")) - .filter(access="EXTERNAL") - .select_related("project") - .select_related("workspace") - .select_related("issue") - .annotate( - is_member=Exists( - ProjectMember.objects.filter( - workspace_id=project_deploy_board.workspace_id, - project_id=project_deploy_board.project_id, - member_id=self.request.user.id, - is_active=True, - ) - ) - ) - .distinct() - ).order_by("created_at") - return IssueComment.objects.none() - except DeployBoard.DoesNotExist: - return IssueComment.objects.none() - - def create(self, request, anchor, issue_id): - project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") - - if not project_deploy_board.is_comments_enabled: - return Response( - {"error": "Comments are not enabled for this project"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - serializer = IssueCommentSerializer(data=request.data) - if serializer.is_valid(): - serializer.save( - project_id=project_deploy_board.project_id, - issue_id=issue_id, - actor=request.user, - access="EXTERNAL", - ) - issue_activity.delay( - type="comment.activity.created", - requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_deploy_board.project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - if not ProjectMember.objects.filter( - project_id=project_deploy_board.project_id, - member=request.user, - is_active=True, - ).exists(): - # Add the user for workspace tracking - _ = ProjectPublicMember.objects.get_or_create( - project_id=project_deploy_board.project_id, member=request.user - ) - - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def partial_update(self, request, anchor, issue_id, pk): - project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") - - if not project_deploy_board.is_comments_enabled: - return Response( - {"error": "Comments are not enabled for this project"}, - status=status.HTTP_400_BAD_REQUEST, - ) - comment = IssueComment.objects.get(pk=pk, actor=request.user) - serializer = IssueCommentSerializer(comment, data=request.data, partial=True) - if serializer.is_valid(): - serializer.save() - issue_activity.delay( - type="comment.activity.updated", - requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_deploy_board.project_id), - current_instance=json.dumps(IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder), - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, anchor, issue_id, pk): - project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") - - if not project_deploy_board.is_comments_enabled: - return Response( - {"error": "Comments are not enabled for this project"}, - status=status.HTTP_400_BAD_REQUEST, - ) - comment = IssueComment.objects.get(pk=pk, actor=request.user) - issue_activity.delay( - type="comment.activity.deleted", - requested_data=json.dumps({"comment_id": str(pk)}), - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_deploy_board.project_id), - current_instance=json.dumps(IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder), - epoch=int(timezone.now().timestamp()), - ) - comment.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class IssueReactionPublicViewSet(BaseViewSet): - serializer_class = IssueReactionSerializer - model = IssueReaction - - def get_queryset(self): - try: - project_deploy_board = DeployBoard.objects.get( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), - ) - if project_deploy_board.is_reactions_enabled: - return ( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(issue_id=self.kwargs.get("issue_id")) - .order_by("-created_at") - .distinct() - ) - return IssueReaction.objects.none() - except DeployBoard.DoesNotExist: - return IssueReaction.objects.none() - - def create(self, request, anchor, issue_id): - project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") - - if not project_deploy_board.is_reactions_enabled: - return Response( - {"error": "Reactions are not enabled for this project board"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - serializer = IssueReactionSerializer(data=request.data) - if serializer.is_valid(): - serializer.save( - project_id=project_deploy_board.project_id, - issue_id=issue_id, - actor=request.user, - ) - if not ProjectMember.objects.filter( - project_id=project_deploy_board.project_id, - member=request.user, - is_active=True, - ).exists(): - # Add the user for workspace tracking - _ = ProjectPublicMember.objects.get_or_create( - project_id=project_deploy_board.project_id, member=request.user - ) - issue_activity.delay( - type="issue_reaction.activity.created", - requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(project_deploy_board.project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, anchor, issue_id, reaction_code): - project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") - - if not project_deploy_board.is_reactions_enabled: - return Response( - {"error": "Reactions are not enabled for this project board"}, - status=status.HTTP_400_BAD_REQUEST, - ) - issue_reaction = IssueReaction.objects.get( - workspace_id=project_deploy_board.workspace_id, - issue_id=issue_id, - reaction=reaction_code, - actor=request.user, - ) - issue_activity.delay( - type="issue_reaction.activity.deleted", - requested_data=None, - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(project_deploy_board.project_id), - current_instance=json.dumps({"reaction": str(reaction_code), "identifier": str(issue_reaction.id)}), - epoch=int(timezone.now().timestamp()), - ) - issue_reaction.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class CommentReactionPublicViewSet(BaseViewSet): - serializer_class = CommentReactionSerializer - model = CommentReaction - - def get_queryset(self): - try: - project_deploy_board = DeployBoard.objects.get(anchor=self.kwargs.get("anchor"), entity_name="project") - if project_deploy_board.is_reactions_enabled: - return ( - super() - .get_queryset() - .filter(workspace_id=project_deploy_board.workspace_id) - .filter(project_id=project_deploy_board.project_id) - .filter(comment_id=self.kwargs.get("comment_id")) - .order_by("-created_at") - .distinct() - ) - return CommentReaction.objects.none() - except DeployBoard.DoesNotExist: - return CommentReaction.objects.none() - - def create(self, request, anchor, comment_id): - project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") - - if not project_deploy_board.is_reactions_enabled: - return Response( - {"error": "Reactions are not enabled for this board"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - serializer = CommentReactionSerializer(data=request.data) - if serializer.is_valid(): - serializer.save( - project_id=project_deploy_board.project_id, - comment_id=comment_id, - actor=request.user, - ) - if not ProjectMember.objects.filter( - project_id=project_deploy_board.project_id, - member=request.user, - is_active=True, - ).exists(): - # Add the user for workspace tracking - _ = ProjectPublicMember.objects.get_or_create( - project_id=project_deploy_board.project_id, member=request.user - ) - issue_activity.delay( - type="comment_reaction.activity.created", - requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), - actor_id=str(self.request.user.id), - issue_id=None, - project_id=str(self.kwargs.get("project_id", None)), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, anchor, comment_id, reaction_code): - project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") - if not project_deploy_board.is_reactions_enabled: - return Response( - {"error": "Reactions are not enabled for this board"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - comment_reaction = CommentReaction.objects.get( - project_id=project_deploy_board.project_id, - workspace_id=project_deploy_board.workspace_id, - comment_id=comment_id, - reaction=reaction_code, - actor=request.user, - ) - issue_activity.delay( - type="comment_reaction.activity.deleted", - requested_data=None, - actor_id=str(self.request.user.id), - issue_id=None, - project_id=str(project_deploy_board.project_id), - current_instance=json.dumps( - { - "reaction": str(reaction_code), - "identifier": str(comment_reaction.id), - "comment_id": str(comment_id), - } - ), - epoch=int(timezone.now().timestamp()), - ) - comment_reaction.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class IssueVotePublicViewSet(BaseViewSet): - model = IssueVote - serializer_class = IssueVoteSerializer - - def get_queryset(self): - try: - project_deploy_board = DeployBoard.objects.get( - workspace__slug=self.kwargs.get("anchor"), entity_name="project" - ) - if project_deploy_board.is_votes_enabled: - return ( - super() - .get_queryset() - .filter(issue_id=self.kwargs.get("issue_id")) - .filter(workspace_id=project_deploy_board.workspace_id) - .filter(project_id=project_deploy_board.project_id) - ) - return IssueVote.objects.none() - except DeployBoard.DoesNotExist: - return IssueVote.objects.none() - - def create(self, request, anchor, issue_id): - project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") - issue_vote, _ = IssueVote.objects.get_or_create( - actor_id=request.user.id, - project_id=project_deploy_board.project_id, - issue_id=issue_id, - ) - # Add the user for workspace tracking - if not ProjectMember.objects.filter( - project_id=project_deploy_board.project_id, - member=request.user, - is_active=True, - ).exists(): - _ = ProjectPublicMember.objects.get_or_create( - project_id=project_deploy_board.project_id, member=request.user - ) - issue_vote.vote = request.data.get("vote", 1) - issue_vote.save() - issue_activity.delay( - type="issue_vote.activity.created", - requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(project_deploy_board.project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - serializer = IssueVoteSerializer(issue_vote) - return Response(serializer.data, status=status.HTTP_201_CREATED) - - def destroy(self, request, anchor, issue_id): - project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") - issue_vote = IssueVote.objects.get( - issue_id=issue_id, - actor_id=request.user.id, - project_id=project_deploy_board.project_id, - workspace_id=project_deploy_board.workspace_id, - ) - issue_activity.delay( - type="issue_vote.activity.deleted", - requested_data=None, - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(project_deploy_board.project_id), - current_instance=json.dumps({"vote": str(issue_vote.vote), "identifier": str(issue_vote.id)}), - epoch=int(timezone.now().timestamp()), - ) - issue_vote.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class IssueRetrievePublicEndpoint(BaseAPIView): - permission_classes = [AllowAny] - - def get(self, request, anchor, issue_id): - deploy_board = DeployBoard.objects.get(anchor=anchor) - - issue_queryset = ( - Issue.issue_objects.filter( - pk=issue_id, - workspace__slug=deploy_board.workspace.slug, - project_id=deploy_board.project_id, - ) - .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels", "issue_module__module") - .annotate( - cycle_id=Subquery( - CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] - ) - ) - .annotate( - label_ids=Coalesce( - ArrayAgg( - "labels__id", - distinct=True, - filter=Q(~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True)), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - assignee_ids=Coalesce( - ArrayAgg( - "assignees__id", - distinct=True, - filter=Q( - ~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True) - & Q(issue_assignee__deleted_at__isnull=True) - ), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - module_ids=Coalesce( - ArrayAgg( - "issue_module__module_id", - distinct=True, - filter=~Q(issue_module__module_id__isnull=True) - & Q(issue_module__module__archived_at__isnull=True) - & Q(issue_module__deleted_at__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - ) - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("issue", "actor"), - ) - ) - .prefetch_related(Prefetch("votes", queryset=IssueVote.objects.select_related("actor"))) - .annotate( - vote_items=ArrayAgg( - Case( - When( - votes__isnull=False, - votes__deleted_at__isnull=True, - then=JSONObject( - vote=F("votes__vote"), - actor_details=JSONObject( - id=F("votes__actor__id"), - first_name=F("votes__actor__first_name"), - last_name=F("votes__actor__last_name"), - avatar=F("votes__actor__avatar"), - avatar_url=Case( - When( - votes__actor__avatar_asset__isnull=False, - then=Concat( - Value("/api/assets/v2/static/"), - F("votes__actor__avatar_asset"), - Value("/"), - ), - ), - When( - votes__actor__avatar_asset__isnull=True, - then=F("votes__actor__avatar"), - ), - default=Value(None), - output_field=CharField(), - ), - display_name=F("votes__actor__display_name"), - ), - ), - ), - default=None, - output_field=JSONField(), - ), - filter=Case( - When( - votes__isnull=False, - votes__deleted_at__isnull=True, - then=True, - ), - default=False, - output_field=JSONField(), - ), - distinct=True, - ), - reaction_items=ArrayAgg( - Case( - When( - issue_reactions__isnull=False, - issue_reactions__deleted_at__isnull=True, - then=JSONObject( - reaction=F("issue_reactions__reaction"), - actor_details=JSONObject( - id=F("issue_reactions__actor__id"), - first_name=F("issue_reactions__actor__first_name"), - last_name=F("issue_reactions__actor__last_name"), - avatar=F("issue_reactions__actor__avatar"), - avatar_url=Case( - When( - votes__actor__avatar_asset__isnull=False, - then=Concat( - Value("/api/assets/v2/static/"), - F("votes__actor__avatar_asset"), - Value("/"), - ), - ), - When( - votes__actor__avatar_asset__isnull=True, - then=F("votes__actor__avatar"), - ), - default=Value(None), - output_field=CharField(), - ), - display_name=F("issue_reactions__actor__display_name"), - ), - ), - ), - default=None, - output_field=JSONField(), - ), - filter=Case( - When( - issue_reactions__isnull=False, - issue_reactions__deleted_at__isnull=True, - then=True, - ), - default=False, - output_field=JSONField(), - ), - distinct=True, - ), - ) - .values( - "id", - "name", - "state_id", - "sort_order", - "description_json", - "description_html", - "description_stripped", - "description_binary", - "module_ids", - "label_ids", - "assignee_ids", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "created_by", - "state__group", - "vote_items", - "reaction_items", - ) - ).first() - - return Response(issue_queryset, status=status.HTTP_200_OK) + \ No newline at end of file diff --git a/apps/api/tests/test_space_issue_security.py b/apps/api/tests/test_space_issue_security.py new file mode 100644 index 00000000000..11542274389 --- /dev/null +++ b/apps/api/tests/test_space_issue_security.py @@ -0,0 +1 @@ + \ No newline at end of file