diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 2b83b0b94..22557464c 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -93,6 +93,7 @@ from plane.api.views import ( IssueRelationViewSet, CommentReactionViewSet, IssueDraftViewSet, + TransferProjectIssueEndpoint, ## End Issues # States StateViewSet, @@ -856,6 +857,11 @@ urlpatterns = [ ExportIssuesEndpoint.as_view(), name="export-issues", ), + path( + "workspaces//projects//transfer-issues/", + TransferProjectIssueEndpoint.as_view(), + name="transfer-issues", + ), ## End Issues ## Issue Activity path( diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 265ed9c90..975a77151 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, + TransferProjectIssueEndpoint, ) from .auth_extended import ( diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 79141d78d..2bcbf5f70 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -279,7 +279,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) @@ -460,10 +461,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) @@ -2114,7 +2116,7 @@ class IssueRelationViewSet(BaseViewSet): project_id=str(project_id), current_instance=None, ) - + if relation == "blocking": return Response( RelatedIssueSerializer(issue_relation, many=True).data, @@ -2157,6 +2159,8 @@ class IssueRelationViewSet(BaseViewSet): .select_related("issue") .distinct() ) + + class IssueRetrievePublicEndpoint(BaseAPIView): permission_classes = [ AllowAny, @@ -2366,7 +2370,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 = ( @@ -2385,7 +2388,6 @@ class IssueDraftViewSet(BaseViewSet): ) return super().perform_update(serializer) - def perform_destroy(self, instance): current_instance = ( @@ -2406,7 +2408,6 @@ class IssueDraftViewSet(BaseViewSet): ) return super().perform_destroy(instance) - def get_queryset(self): return ( Issue.objects.annotate( @@ -2432,7 +2433,6 @@ class IssueDraftViewSet(BaseViewSet): ) ) - @method_decorator(gzip_page) def list(self, request, slug, project_id): try: @@ -2541,7 +2541,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) @@ -2575,7 +2574,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( @@ -2586,4 +2584,68 @@ class IssueDraftViewSet(BaseViewSet): return Response( {"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND ) - + + +class TransferProjectIssueEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def post(self, request, slug, project_id): + try: + issue_ids = request.data.get("issue_ids", []) + transfer_project_id = request.data.get("transfer_project_id", False) + + if not issue_ids or not transfer_project_id: + return Response( + {"error": "Issue ids and transafer project id is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # The project that all issues need to be transfered + transfer_project = Project.objects.get( + workspace__slug=slug, pk=transfer_project_id + ) + + # Fetch all the issues + issues = Issue.objects.filter( + workspace__slug=slug, project_id=project_id, pk__in=issue_ids + ) + + bulk_issues = [] + for issue in issues: + issue.project_id = transfer_project_id + bulk_issues.append(issue) + + moved_issues_count = Issue.objects.bulk_update( + bulk_issues, ["project_id"], batch_size=100 + ) + + if moved_issues_count: + [ + issue_activity.delay( + type="issue.transfer.activity", + issue_id=str(issue.id), + requested_data=json.dumps({"old_project_id": str(project_id)}), + current_instance=None, + project_id=transfer_project_id, + actor_id=request.user.id, + ) + for issue in bulk_issues + ] + + return Response( + {"message": f"{moved_issues_count} issue(s) moved to {transfer_project.name}"}, + status=status.HTTP_200_OK, + ) + except Project.DoesNotExist: + return Response( + {"error": "Transfer project does not exist"}, + 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/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 4f8ccfab1..e401c040e 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -1191,6 +1191,29 @@ def delete_draft_issue_activity( ) ) + +def transfer_issue_activity(requested_data, current_instance, issue_id, project, actor, issue_activities): + + requested_data = json.loads(requested_data) if requested_data is not None else None + + # Old project + old_project = Project.objects.get(pk=requested_data.get("old_project_id")) + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + verb="updated", + project=project, + workspace=project.workspace, + comment=f"moved the issue", + old_identifier=requested_data.get("old_project_id"), + new_identifier=project.id, + old_value=old_project.name, + new_value=project.name, + actor=actor, + ) + ) + # Receive message from room group @shared_task def issue_activity( @@ -1265,6 +1288,7 @@ def issue_activity( "issue_draft.activity.created": create_draft_issue_activity, "issue_draft.activity.updated": update_draft_issue_activity, "issue_draft.activity.deleted": delete_draft_issue_activity, + "issue.transfer.activity": transfer_issue_activity, } func = ACTIVITY_MAPPER.get(type)