From 374e52e75a8a1344cda64da53485325dcc5e9465 Mon Sep 17 00:00:00 2001 From: pablohashescobar <nikhilschacko@gmail.com> Date: Thu, 21 Sep 2023 14:20:45 +0530 Subject: [PATCH] feat: bulk issue operations --- apiserver/plane/api/urls.py | 8 + apiserver/plane/api/views/__init__.py | 1 + apiserver/plane/api/views/issue.py | 303 +++++++++++++++--- ..._alter_analyticview_created_by_and_more.py | 19 ++ apiserver/plane/db/models/issue.py | 1 + 5 files changed, 289 insertions(+), 43 deletions(-) create mode 100644 apiserver/plane/db/migrations/0047_alter_analyticview_created_by_and_more.py diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index c10c4a745..1dbb8ede5 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -186,6 +186,9 @@ from plane.api.views import ( ## Exporter ExportIssuesEndpoint, ## End Exporter + # Bulk Issue Operations + BulkIssueOperationsEndpoint, + ## End Bulk Issue Operations ) @@ -1728,4 +1731,9 @@ urlpatterns = [ name="workspace-project-boards", ), ## End Public Boards + path( + "workspaces/<str:slug>/projects/<uuid:project_id>/bulk-operation-issues/", + BulkIssueOperationsEndpoint.as_view(), + name="bulk-issue-operation", + ), ] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index c03d6d5b7..244eb32a2 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -90,6 +90,7 @@ from .issue import ( IssueRetrievePublicEndpoint, ProjectIssuesPublicEndpoint, IssueDraftViewSet, + BulkIssueOperationsEndpoint, ) from .auth_extended import ( diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index e653f3d44..b1418eb56 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -82,6 +82,8 @@ from plane.db.models import ( IssueVote, IssueRelation, ProjectPublicMember, + IssueLabel, + IssueAssignee, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results @@ -130,7 +132,7 @@ class IssueViewSet(BaseViewSet): current_instance=json.dumps( IssueSerializer(current_instance).data, cls=DjangoJSONEncoder ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) return super().perform_update(serializer) @@ -151,7 +153,7 @@ class IssueViewSet(BaseViewSet): current_instance=json.dumps( IssueSerializer(current_instance).data, cls=DjangoJSONEncoder ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) return super().perform_destroy(instance) @@ -282,7 +284,8 @@ class IssueViewSet(BaseViewSet): if group_by: return Response( - group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK + group_results(issues, group_by, sub_group_by), + status=status.HTTP_200_OK, ) return Response(issues, status=status.HTTP_200_OK) @@ -318,7 +321,7 @@ class IssueViewSet(BaseViewSet): issue_id=str(serializer.data.get("id", None)), project_id=str(project_id), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -335,9 +338,7 @@ class IssueViewSet(BaseViewSet): .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") - ).get( - workspace__slug=slug, project_id=project_id, pk=pk - ) + ).get(workspace__slug=slug, project_id=project_id, pk=pk) return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) except Issue.DoesNotExist: return Response( @@ -469,10 +470,11 @@ class UserWorkSpaceIssues(BaseAPIView): {"error": "Group by and sub group by cannot be same"}, status=status.HTTP_400_BAD_REQUEST, ) - + if group_by: return Response( - group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK + group_results(issues, group_by, sub_group_by), + status=status.HTTP_200_OK, ) return Response(issues, status=status.HTTP_200_OK) @@ -577,7 +579,7 @@ class IssueCommentViewSet(BaseViewSet): issue_id=str(self.kwargs.get("issue_id")), project_id=str(self.kwargs.get("project_id")), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) def perform_update(self, serializer): @@ -596,7 +598,7 @@ class IssueCommentViewSet(BaseViewSet): IssueCommentSerializer(current_instance).data, cls=DjangoJSONEncoder, ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) return super().perform_update(serializer) @@ -618,7 +620,7 @@ class IssueCommentViewSet(BaseViewSet): IssueCommentSerializer(current_instance).data, cls=DjangoJSONEncoder, ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) return super().perform_destroy(instance) @@ -902,7 +904,7 @@ class IssueLinkViewSet(BaseViewSet): issue_id=str(self.kwargs.get("issue_id")), project_id=str(self.kwargs.get("project_id")), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) def perform_update(self, serializer): @@ -921,7 +923,7 @@ class IssueLinkViewSet(BaseViewSet): IssueLinkSerializer(current_instance).data, cls=DjangoJSONEncoder, ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) return super().perform_update(serializer) @@ -943,7 +945,7 @@ class IssueLinkViewSet(BaseViewSet): IssueLinkSerializer(current_instance).data, cls=DjangoJSONEncoder, ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) return super().perform_destroy(instance) @@ -1022,7 +1024,7 @@ class IssueAttachmentEndpoint(BaseAPIView): serializer.data, cls=DjangoJSONEncoder, ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1045,7 +1047,7 @@ class IssueAttachmentEndpoint(BaseAPIView): issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -1248,7 +1250,7 @@ class IssueArchiveViewSet(BaseViewSet): issue_id=str(issue.id), project_id=str(project_id), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) @@ -1453,7 +1455,7 @@ class IssueReactionViewSet(BaseViewSet): issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) def destroy(self, request, slug, project_id, issue_id, reaction_code): @@ -1477,7 +1479,7 @@ class IssueReactionViewSet(BaseViewSet): "identifier": str(issue_reaction.id), } ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) issue_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1526,7 +1528,7 @@ class CommentReactionViewSet(BaseViewSet): issue_id=None, project_id=str(self.kwargs.get("project_id", None)), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) def destroy(self, request, slug, project_id, comment_id, reaction_code): @@ -1551,7 +1553,7 @@ class CommentReactionViewSet(BaseViewSet): "comment_id": str(comment_id), } ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) comment_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1648,7 +1650,7 @@ class IssueCommentPublicViewSet(BaseViewSet): issue_id=str(issue_id), project_id=str(project_id), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) if not ProjectMember.objects.filter( project_id=project_id, @@ -1698,7 +1700,7 @@ class IssueCommentPublicViewSet(BaseViewSet): IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder, ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1732,7 +1734,7 @@ class IssueCommentPublicViewSet(BaseViewSet): IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder, ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) comment.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1807,7 +1809,7 @@ class IssueReactionPublicViewSet(BaseViewSet): issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1852,7 +1854,7 @@ class IssueReactionPublicViewSet(BaseViewSet): "identifier": str(issue_reaction.id), } ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) issue_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1926,7 +1928,7 @@ class CommentReactionPublicViewSet(BaseViewSet): issue_id=None, project_id=str(self.kwargs.get("project_id", None)), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1978,7 +1980,7 @@ class CommentReactionPublicViewSet(BaseViewSet): "comment_id": str(comment_id), } ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) comment_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -2042,7 +2044,7 @@ class IssueVotePublicViewSet(BaseViewSet): issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) serializer = IssueVoteSerializer(issue_vote) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -2077,7 +2079,7 @@ class IssueVotePublicViewSet(BaseViewSet): "identifier": str(issue_vote.id), } ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) issue_vote.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -2111,7 +2113,7 @@ class IssueRelationViewSet(BaseViewSet): IssueRelationSerializer(current_instance).data, cls=DjangoJSONEncoder, ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) return super().perform_destroy(instance) @@ -2145,9 +2147,9 @@ class IssueRelationViewSet(BaseViewSet): issue_id=str(issue_id), project_id=str(project_id), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) - + if relation == "blocking": return Response( RelatedIssueSerializer(issue_relation, many=True).data, @@ -2401,7 +2403,6 @@ class IssueDraftViewSet(BaseViewSet): serializer_class = IssueFlatSerializer model = Issue - def perform_update(self, serializer): requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) current_instance = ( @@ -2417,11 +2418,10 @@ class IssueDraftViewSet(BaseViewSet): current_instance=json.dumps( IssueSerializer(current_instance).data, cls=DjangoJSONEncoder ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) return super().perform_update(serializer) - def perform_destroy(self, instance): current_instance = ( @@ -2442,7 +2442,6 @@ class IssueDraftViewSet(BaseViewSet): ) return super().perform_destroy(instance) - def get_queryset(self): return ( Issue.objects.annotate( @@ -2468,7 +2467,6 @@ class IssueDraftViewSet(BaseViewSet): ) ) - @method_decorator(gzip_page) def list(self, request, slug, project_id): try: @@ -2577,7 +2575,6 @@ class IssueDraftViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - def create(self, request, slug, project_id): try: project = Project.objects.get(pk=project_id) @@ -2602,7 +2599,7 @@ class IssueDraftViewSet(BaseViewSet): issue_id=str(serializer.data.get("id", None)), project_id=str(project_id), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -2612,7 +2609,6 @@ class IssueDraftViewSet(BaseViewSet): {"error": "Project was not found"}, status=status.HTTP_404_NOT_FOUND ) - def retrieve(self, request, slug, project_id, pk=None): try: issue = Issue.objects.get( @@ -2623,4 +2619,225 @@ class IssueDraftViewSet(BaseViewSet): return Response( {"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND ) - + + +class BulkIssueOperationsEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def post(self, request, slug, project_id): + try: + issue_ids = request.data.get("issue_ids", []) + # Get all the issues + issues = ( + Issue.objects.filter( + workspace__slug=slug, project_id=project_id, pk__in=issue_ids + ) + .select_related("state") + .prefetch_related("labels", "assignees") + ) + # Current epoch + epoch = int(timezone.now().timestamp()) + + # Project details + project = Project.objects.get(workspace__slug=slug, pk=project_id) + workspace_id = project.workspace_id + + # Initialize arrays + bulk_update_issues = [] + bulk_issue_activities = [] + bulk_update_issue_labels = [] + bulk_update_issue_assignees = [] + + properties = request.data.get("properties", {}) + + for issue in issues: + # Priority + if properties.get("priority", False): + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"priority": properties.get("priority")} + ), + "current_instance": json.dumps( + {"priority": (issue.priority)} + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + issue.priority = properties.get("priority") + # State + if properties.get("state", False): + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"state": properties.get("state_id")} + ), + "current_instance": json.dumps({"state": (issue.state_id)}), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + issue.state_id = properties.get("state") + # Start date + if properties.get("start_date", False): + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"start_date": properties.get("start_date")} + ), + "current_instance": json.dumps( + {"start_date": (issue.start_date)} + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + issue.start_date = properties.get("start_date") + # Target date + if properties.get("target_date", False): + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"target_date": properties.get("requested_data")} + ), + "current_instance": json.dumps( + {"target_date": (issue.target_date)} + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + issue.target_date = properties.get("target_date") + # Estimate point + if properties.get("estimate_point", False): + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"estimate_point": properties.get("estimate_point")} + ), + "current_instance": json.dumps( + {"estimate_point": (issue.estimate_point)} + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + issue.estimate_point = properties.get("estimate_point") + + bulk_update_issues.append(issue) + + # Labels + if properties.get("labels_list", []): + for label_id in properties.get("labels_list", []): + bulk_update_issue_labels.append( + IssueLabel( + issue=issue, + label_id=label_id, + created_by=request.user, + project_id=project_id, + workspace_id=workspace_id, + ) + ) + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"labels_list": properties.get("labels_list", [])} + ), + "current_instance": json.dumps( + {"labels": [str(label.id) for label in issue.labels.all()]} + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + + # Assignees + if properties.get("assignees_list", []): + for assignee_id in properties.get( + "assignees_list", issue.assignees + ): + bulk_update_issue_assignees.append( + IssueAssignee( + issue=issue, + assignee_id=assignee_id, + created_by=request.user, + project_id=project_id, + workspace_id=workspace_id, + ) + ) + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"assignees_list": properties.get("assignees_list", [])} + ), + "current_instance": json.dumps( + { + "assignees": [ + str(assignee.id) + for assignee in issue.assignees.all() + ] + } + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + + # Bulk update all the objects + Issue.objects.bulk_update( + bulk_update_issues, + ["priority", "estimate_point", "start_date", "target_date", "state"], + batch_size=100, + ) + + # Create new labels + IssueLabel.objects.bulk_create( + bulk_update_issue_labels, + ignore_conflicts=True, + batch_size=100, + ) + + # Create new assignees + IssueAssignee.objects.bulk_create( + bulk_update_issue_assignees, + ignore_conflicts=True, + batch_size=100, + ) + + [issue_activity.delay(**activity) for activity in bulk_issue_activities] + + return Response(status=status.HTTP_204_NO_CONTENT) + except Project.DoesNotExist: + return Response( + {"error": "Project does not exists"}, status=status.HTTP_400_BAD_REQUEST + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/db/migrations/0047_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0047_alter_analyticview_created_by_and_more.py new file mode 100644 index 000000000..03521a882 --- /dev/null +++ b/apiserver/plane/db/migrations/0047_alter_analyticview_created_by_and_more.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.3 on 2023-09-21 08:05 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0046_auto_20230919_1421'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='issuelabel', + unique_together={('issue', 'label')}, + ), + ] diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 3ba054d49..c50fb1df5 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -413,6 +413,7 @@ class IssueLabel(ProjectBaseModel): ) class Meta: + unique_together = ["issue", "label"] verbose_name = "Issue Label" verbose_name_plural = "Issue Labels" db_table = "issue_labels"