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"