# Python imports import json import uuid # Django imports from django.core.serializers.json import DjangoJSONEncoder from django.http import HttpResponseRedirect from django.db import IntegrityError from django.db.models import ( Case, CharField, Exists, F, Func, Max, OuterRef, Q, Value, When, Subquery, ) from django.utils import timezone from django.conf import settings # Third party imports from rest_framework import status from rest_framework.response import Response # Module imports from plane.api.serializers import ( IssueAttachmentSerializer, IssueActivitySerializer, IssueCommentSerializer, IssueLinkSerializer, IssueSerializer, LabelSerializer, ) from plane.app.permissions import ( ProjectEntityPermission, ProjectLitePermission, ProjectMemberPermission, ) from plane.bgtasks.issue_activities_task import issue_activity from plane.db.models import ( Issue, IssueActivity, FileAsset, IssueComment, IssueLink, Label, Project, ProjectMember, CycleIssue, Workspace, ) from plane.settings.storage import S3Storage from plane.bgtasks.storage_metadata_task import get_asset_object_metadata from .base import BaseAPIView class WorkspaceIssueAPIEndpoint(BaseAPIView): """ This viewset provides `retrieveByIssueId` on workspace level """ model = Issue webhook_event = "issue" permission_classes = [ProjectEntityPermission] serializer_class = IssueSerializer @property def project__identifier(self): return self.kwargs.get("project__identifier", None) def get_queryset(self): return ( Issue.issue_objects.annotate( sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .filter(workspace__slug=self.kwargs.get("slug")) .filter(project__identifier=self.kwargs.get("project__identifier")) .select_related("project") .select_related("workspace") .select_related("state") .select_related("parent") .prefetch_related("assignees") .prefetch_related("labels") .order_by(self.kwargs.get("order_by", "-created_at")) ).distinct() def get(self, request, slug, project__identifier=None, issue__identifier=None): if issue__identifier and project__identifier: issue = Issue.issue_objects.annotate( sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ).get( workspace__slug=slug, project__identifier=project__identifier, sequence_id=issue__identifier, ) return Response( IssueSerializer(issue, fields=self.fields, expand=self.expand).data, status=status.HTTP_200_OK, ) class IssueAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, `update` and `destroy` actions related to issue. """ model = Issue webhook_event = "issue" permission_classes = [ProjectEntityPermission] serializer_class = IssueSerializer def get_queryset(self): return ( Issue.issue_objects.annotate( sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .filter(project_id=self.kwargs.get("project_id")) .filter(workspace__slug=self.kwargs.get("slug")) .select_related("project") .select_related("workspace") .select_related("state") .select_related("parent") .prefetch_related("assignees") .prefetch_related("labels") .order_by(self.kwargs.get("order_by", "-created_at")) ).distinct() def get(self, request, slug, project_id, pk=None): external_id = request.GET.get("external_id") external_source = request.GET.get("external_source") if external_id and external_source: issue = Issue.objects.get( external_id=external_id, external_source=external_source, workspace__slug=slug, project_id=project_id, ) return Response( IssueSerializer(issue, fields=self.fields, expand=self.expand).data, status=status.HTTP_200_OK, ) if pk: issue = Issue.issue_objects.annotate( sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ).get(workspace__slug=slug, project_id=project_id, pk=pk) return Response( IssueSerializer(issue, fields=self.fields, expand=self.expand).data, status=status.HTTP_200_OK, ) # Custom ordering for priority and state priority_order = ["urgent", "high", "medium", "low", "none"] state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = ( self.get_queryset() .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") ) ) # Priority Ordering if order_by_param == "priority" or order_by_param == "-priority": priority_order = ( priority_order if order_by_param == "priority" else priority_order[::-1] ) issue_queryset = issue_queryset.annotate( priority_order=Case( *[ When(priority=p, then=Value(i)) for i, p in enumerate(priority_order) ], output_field=CharField(), ) ).order_by("priority_order") # State Ordering elif order_by_param in [ "state__name", "state__group", "-state__name", "-state__group", ]: state_order = ( state_order if order_by_param in ["state__name", "state__group"] else state_order[::-1] ) issue_queryset = issue_queryset.annotate( state_order=Case( *[ When(state__group=state_group, then=Value(i)) for i, state_group in enumerate(state_order) ], default=Value(len(state_order)), output_field=CharField(), ) ).order_by("state_order") # assignee and label ordering elif order_by_param in [ "labels__name", "-labels__name", "assignees__first_name", "-assignees__first_name", ]: issue_queryset = issue_queryset.annotate( max_values=Max( order_by_param[1::] if order_by_param.startswith("-") else order_by_param ) ).order_by( "-max_values" if order_by_param.startswith("-") else "max_values" ) else: issue_queryset = issue_queryset.order_by(order_by_param) return self.paginate( request=request, queryset=(issue_queryset), on_results=lambda issues: IssueSerializer( issues, many=True, fields=self.fields, expand=self.expand ).data, ) def post(self, request, slug, project_id): project = Project.objects.get(pk=project_id) serializer = IssueSerializer( data=request.data, context={ "project_id": project_id, "workspace_id": project.workspace_id, "default_assignee_id": project.default_assignee_id, }, ) if serializer.is_valid(): if ( request.data.get("external_id") and request.data.get("external_source") and Issue.objects.filter( project_id=project_id, workspace__slug=slug, external_source=request.data.get("external_source"), external_id=request.data.get("external_id"), ).exists() ): issue = Issue.objects.filter( workspace__slug=slug, project_id=project_id, external_id=request.data.get("external_id"), external_source=request.data.get("external_source"), ).first() return Response( { "error": "Issue with the same external id and external source already exists", "id": str(issue.id), }, status=status.HTTP_409_CONFLICT, ) serializer.save() # Refetch the issue issue = Issue.objects.filter( workspace__slug=slug, project_id=project_id, pk=serializer.data["id"] ).first() issue.created_at = request.data.get("created_at", timezone.now()) issue.created_by_id = request.data.get("created_by", request.user.id) issue.save(update_fields=["created_at", "created_by"]) # Track the issue issue_activity.delay( type="issue.activity.created", requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), actor_id=str(request.user.id), issue_id=str(serializer.data.get("id", None)), project_id=str(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 put(self, request, slug, project_id): # Get the entities required for putting the issue, external_id and # external_source are must to identify the issue here project = Project.objects.get(pk=project_id) external_id = request.data.get("external_id") external_source = request.data.get("external_source") # If the external_id and source are present, we need to find the exact # issue that needs to be updated with the provided external_id and # external_source if external_id and external_source: try: issue = Issue.objects.get( project_id=project_id, workspace__slug=slug, external_id=external_id, external_source=external_source, ) # Get the current instance of the issue in order to track # changes and dispatch the issue activity current_instance = json.dumps( IssueSerializer(issue).data, cls=DjangoJSONEncoder ) # Get the requested data, encode it as django object and pass it # to serializer to validation requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) serializer = IssueSerializer( issue, data=request.data, context={ "project_id": project_id, "workspace_id": project.workspace_id, }, partial=True, ) if serializer.is_valid(): # If the serializer is valid, save the issue and dispatch # the update issue activity worker event. serializer.save() issue_activity.delay( type="issue.activity.updated", requested_data=requested_data, actor_id=str(request.user.id), issue_id=str(issue.id), project_id=str(project_id), current_instance=current_instance, epoch=int(timezone.now().timestamp()), ) return Response(serializer.data, status=status.HTTP_200_OK) return Response( # If the serializer is not valid, respond with 400 bad # request serializer.errors, status=status.HTTP_400_BAD_REQUEST, ) except Issue.DoesNotExist: # If the issue does not exist, a new record needs to be created # for the requested data. # Serialize the data with the context of the project and # workspace serializer = IssueSerializer( data=request.data, context={ "project_id": project_id, "workspace_id": project.workspace_id, "default_assignee_id": project.default_assignee_id, }, ) # If the serializer is valid, save the issue and dispatch the # issue activity worker event as created if serializer.is_valid(): serializer.save() # Refetch the issue issue = Issue.objects.filter( workspace__slug=slug, project_id=project_id, pk=serializer.data["id"], ).first() # If any of the created_at or created_by is present, update # the issue with the provided data, else return with the # default states given. issue.created_at = request.data.get("created_at", timezone.now()) issue.created_by_id = request.data.get( "created_by", request.user.id ) issue.save(update_fields=["created_at", "created_by"]) issue_activity.delay( type="issue.activity.created", requested_data=json.dumps( self.request.data, cls=DjangoJSONEncoder ), actor_id=str(request.user.id), issue_id=str(serializer.data.get("id", None)), project_id=str(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) else: return Response( {"error": "external_id and external_source are required"}, status=status.HTTP_400_BAD_REQUEST, ) def patch(self, request, slug, project_id, pk=None): issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) project = Project.objects.get(pk=project_id) current_instance = json.dumps( IssueSerializer(issue).data, cls=DjangoJSONEncoder ) requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) serializer = IssueSerializer( issue, data=request.data, context={"project_id": project_id, "workspace_id": project.workspace_id}, partial=True, ) if serializer.is_valid(): if ( request.data.get("external_id") and (issue.external_id != str(request.data.get("external_id"))) and Issue.objects.filter( project_id=project_id, workspace__slug=slug, external_source=request.data.get( "external_source", issue.external_source ), external_id=request.data.get("external_id"), ).exists() ): return Response( { "error": "Issue with the same external id and external source already exists", "id": str(issue.id), }, status=status.HTTP_409_CONFLICT, ) serializer.save() issue_activity.delay( type="issue.activity.updated", requested_data=requested_data, actor_id=str(request.user.id), issue_id=str(pk), project_id=str(project_id), current_instance=current_instance, 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 delete(self, request, slug, project_id, pk=None): issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) if issue.created_by_id != request.user.id and ( not ProjectMember.objects.filter( workspace__slug=slug, member=request.user, role=20, project_id=project_id, is_active=True, ).exists() ): return Response( {"error": "Only admin or creator can delete the issue"}, status=status.HTTP_403_FORBIDDEN, ) current_instance = json.dumps( IssueSerializer(issue).data, cls=DjangoJSONEncoder ) issue.delete() issue_activity.delay( type="issue.activity.deleted", requested_data=json.dumps({"issue_id": str(pk)}), actor_id=str(request.user.id), issue_id=str(pk), project_id=str(project_id), current_instance=current_instance, epoch=int(timezone.now().timestamp()), ) return Response(status=status.HTTP_204_NO_CONTENT) class LabelAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, `update` and `destroy` actions related to the labels. """ serializer_class = LabelSerializer model = Label permission_classes = [ProjectMemberPermission] def get_queryset(self): return ( Label.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter( project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, ) .filter(project__archived_at__isnull=True) .select_related("project") .select_related("workspace") .select_related("parent") .distinct() .order_by(self.kwargs.get("order_by", "-created_at")) ) def post(self, request, slug, project_id): try: serializer = LabelSerializer(data=request.data) if serializer.is_valid(): if ( request.data.get("external_id") and request.data.get("external_source") and Label.objects.filter( project_id=project_id, workspace__slug=slug, external_source=request.data.get("external_source"), external_id=request.data.get("external_id"), ).exists() ): label = Label.objects.filter( workspace__slug=slug, project_id=project_id, external_id=request.data.get("external_id"), external_source=request.data.get("external_source"), ).first() return Response( { "error": "Label with the same external id and external source already exists", "id": str(label.id), }, status=status.HTTP_409_CONFLICT, ) serializer.save(project_id=project_id) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except IntegrityError: label = Label.objects.filter( workspace__slug=slug, project_id=project_id, name=request.data.get("name"), ).first() return Response( { "error": "Label with the same name already exists in the project", "id": str(label.id), }, status=status.HTTP_409_CONFLICT, ) def get(self, request, slug, project_id, pk=None): if pk is None: return self.paginate( request=request, queryset=(self.get_queryset()), on_results=lambda labels: LabelSerializer( labels, many=True, fields=self.fields, expand=self.expand ).data, ) label = self.get_queryset().get(pk=pk) serializer = LabelSerializer(label, fields=self.fields, expand=self.expand) return Response(serializer.data, status=status.HTTP_200_OK) def patch(self, request, slug, project_id, pk=None): label = self.get_queryset().get(pk=pk) serializer = LabelSerializer(label, data=request.data, partial=True) if serializer.is_valid(): if ( str(request.data.get("external_id")) and (label.external_id != str(request.data.get("external_id"))) and Issue.objects.filter( project_id=project_id, workspace__slug=slug, external_source=request.data.get( "external_source", label.external_source ), external_id=request.data.get("external_id"), ).exists() ): return Response( { "error": "Label with the same external id and external source already exists", "id": str(label.id), }, status=status.HTTP_409_CONFLICT, ) serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def delete(self, request, slug, project_id, pk=None): label = self.get_queryset().get(pk=pk) label.delete() return Response(status=status.HTTP_204_NO_CONTENT) class IssueLinkAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, `update` and `destroy` actions related to the links of the particular issue. """ permission_classes = [ProjectEntityPermission] model = IssueLink serializer_class = IssueLinkSerializer def get_queryset(self): return ( IssueLink.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(issue_id=self.kwargs.get("issue_id")) .filter( project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, ) .filter(project__archived_at__isnull=True) .order_by(self.kwargs.get("order_by", "-created_at")) .distinct() ) def get(self, request, slug, project_id, issue_id, pk=None): if pk is None: issue_links = self.get_queryset() serializer = IssueLinkSerializer( issue_links, fields=self.fields, expand=self.expand ) return self.paginate( request=request, queryset=(self.get_queryset()), on_results=lambda issue_links: IssueLinkSerializer( issue_links, many=True, fields=self.fields, expand=self.expand ).data, ) issue_link = self.get_queryset().get(pk=pk) serializer = IssueLinkSerializer( issue_link, fields=self.fields, expand=self.expand ) return Response(serializer.data, status=status.HTTP_200_OK) def post(self, request, slug, project_id, issue_id): serializer = IssueLinkSerializer(data=request.data) if serializer.is_valid(): serializer.save(project_id=project_id, issue_id=issue_id) link = IssueLink.objects.get(pk=serializer.data["id"]) link.created_by_id = request.data.get("created_by", request.user.id) link.save(update_fields=["created_by"]) issue_activity.delay( type="link.activity.created", requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), issue_id=str(self.kwargs.get("issue_id")), project_id=str(self.kwargs.get("project_id")), actor_id=str(link.created_by_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 patch(self, request, slug, project_id, issue_id, pk): issue_link = IssueLink.objects.get( workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk ) requested_data = json.dumps(request.data, cls=DjangoJSONEncoder) current_instance = json.dumps( IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder ) serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) if serializer.is_valid(): serializer.save() issue_activity.delay( type="link.activity.updated", requested_data=requested_data, actor_id=str(request.user.id), issue_id=str(issue_id), project_id=str(project_id), current_instance=current_instance, 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 delete(self, request, slug, project_id, issue_id, pk): issue_link = IssueLink.objects.get( workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk ) current_instance = json.dumps( IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder ) issue_activity.delay( type="link.activity.deleted", requested_data=json.dumps({"link_id": str(pk)}), actor_id=str(request.user.id), issue_id=str(issue_id), project_id=str(project_id), current_instance=current_instance, epoch=int(timezone.now().timestamp()), ) issue_link.delete() return Response(status=status.HTTP_204_NO_CONTENT) class IssueCommentAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, `update` and `destroy` actions related to comments of the particular issue. """ serializer_class = IssueCommentSerializer model = IssueComment webhook_event = "issue_comment" permission_classes = [ProjectLitePermission] def get_queryset(self): return ( IssueComment.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(issue_id=self.kwargs.get("issue_id")) .filter( project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, ) .filter(project__archived_at__isnull=True) .select_related("workspace", "project", "issue", "actor") .annotate( is_member=Exists( ProjectMember.objects.filter( workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), member_id=self.request.user.id, is_active=True, ) ) ) .order_by(self.kwargs.get("order_by", "-created_at")) .distinct() ) def get(self, request, slug, project_id, issue_id, pk=None): if pk: issue_comment = self.get_queryset().get(pk=pk) serializer = IssueCommentSerializer( issue_comment, fields=self.fields, expand=self.expand ) return Response(serializer.data, status=status.HTTP_200_OK) return self.paginate( request=request, queryset=(self.get_queryset()), on_results=lambda issue_comment: IssueCommentSerializer( issue_comment, many=True, fields=self.fields, expand=self.expand ).data, ) def post(self, request, slug, project_id, issue_id): # Validation check if the issue already exists if ( request.data.get("external_id") and request.data.get("external_source") and IssueComment.objects.filter( project_id=project_id, workspace__slug=slug, external_source=request.data.get("external_source"), external_id=request.data.get("external_id"), ).exists() ): issue_comment = IssueComment.objects.filter( workspace__slug=slug, project_id=project_id, external_id=request.data.get("external_id"), external_source=request.data.get("external_source"), ).first() return Response( { "error": "Issue Comment with the same external id and external source already exists", "id": str(issue_comment.id), }, status=status.HTTP_409_CONFLICT, ) serializer = IssueCommentSerializer(data=request.data) if serializer.is_valid(): serializer.save( project_id=project_id, issue_id=issue_id, actor=request.user ) issue_comment = IssueComment.objects.get(pk=serializer.data.get("id")) # Update the created_at and the created_by and save the comment issue_comment.created_at = request.data.get("created_at", timezone.now()) issue_comment.created_by_id = request.data.get( "created_by", request.user.id ) issue_comment.save(update_fields=["created_at", "created_by"]) issue_activity.delay( type="comment.activity.created", requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), actor_id=str(issue_comment.created_by_id), issue_id=str(self.kwargs.get("issue_id")), project_id=str(self.kwargs.get("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 patch(self, request, slug, project_id, issue_id, pk): issue_comment = IssueComment.objects.get( workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk ) requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) current_instance = json.dumps( IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder ) # Validation check if the issue already exists if ( request.data.get("external_id") and (issue_comment.external_id != str(request.data.get("external_id"))) and IssueComment.objects.filter( project_id=project_id, workspace__slug=slug, external_source=request.data.get( "external_source", issue_comment.external_source ), external_id=request.data.get("external_id"), ).exists() ): return Response( { "error": "Issue Comment with the same external id and external source already exists", "id": str(issue_comment.id), }, status=status.HTTP_409_CONFLICT, ) serializer = IssueCommentSerializer( issue_comment, data=request.data, partial=True ) if serializer.is_valid(): serializer.save() issue_activity.delay( type="comment.activity.updated", requested_data=requested_data, actor_id=str(request.user.id), issue_id=str(issue_id), project_id=str(project_id), current_instance=current_instance, 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 delete(self, request, slug, project_id, issue_id, pk): issue_comment = IssueComment.objects.get( workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk ) current_instance = json.dumps( IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder ) issue_comment.delete() 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_id), current_instance=current_instance, epoch=int(timezone.now().timestamp()), ) return Response(status=status.HTTP_204_NO_CONTENT) class IssueActivityAPIEndpoint(BaseAPIView): permission_classes = [ProjectEntityPermission] def get(self, request, slug, project_id, issue_id, pk=None): issue_activities = ( IssueActivity.objects.filter( issue_id=issue_id, workspace__slug=slug, project_id=project_id ) .filter( ~Q(field__in=["comment", "vote", "reaction", "draft"]), project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, ) .filter(project__archived_at__isnull=True) .select_related("actor", "workspace", "issue", "project") ).order_by(request.GET.get("order_by", "created_at")) if pk: issue_activities = issue_activities.get(pk=pk) serializer = IssueActivitySerializer(issue_activities) return Response(serializer.data, status=status.HTTP_200_OK) return self.paginate( request=request, queryset=(issue_activities), on_results=lambda issue_activity: IssueActivitySerializer( issue_activity, many=True, fields=self.fields, expand=self.expand ).data, ) class IssueAttachmentEndpoint(BaseAPIView): serializer_class = IssueAttachmentSerializer permission_classes = [ProjectEntityPermission] model = FileAsset def post(self, request, slug, project_id, issue_id): name = request.data.get("name") type = request.data.get("type", False) size = request.data.get("size") external_id = request.data.get("external_id") external_source = request.data.get("external_source") # Check if the request is valid if not name or not size: return Response( {"error": "Invalid request.", "status": False}, status=status.HTTP_400_BAD_REQUEST, ) size_limit = min(size, settings.FILE_SIZE_LIMIT) if not type or type not in settings.ATTACHMENT_MIME_TYPES: return Response( {"error": "Invalid file type.", "status": False}, status=status.HTTP_400_BAD_REQUEST, ) # Get the workspace workspace = Workspace.objects.get(slug=slug) # asset key asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" if ( request.data.get("external_id") and request.data.get("external_source") and FileAsset.objects.filter( project_id=project_id, workspace__slug=slug, external_source=request.data.get("external_source"), external_id=request.data.get("external_id"), issue_id=issue_id, entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ).exists() ): asset = FileAsset.objects.filter( project_id=project_id, workspace__slug=slug, external_source=request.data.get("external_source"), external_id=request.data.get("external_id"), issue_id=issue_id, entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ).first() return Response( { "error": "Issue with the same external id and external source already exists", "id": str(asset.id), }, status=status.HTTP_409_CONFLICT, ) # Create a File Asset asset = FileAsset.objects.create( attributes={"name": name, "type": type, "size": size_limit}, asset=asset_key, size=size_limit, workspace_id=workspace.id, created_by=request.user, issue_id=issue_id, project_id=project_id, entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, external_id=external_id, external_source=external_source, ) # Get the presigned URL storage = S3Storage(request=request) # Generate a presigned URL to share an S3 object presigned_url = storage.generate_presigned_post( object_name=asset_key, file_type=type, file_size=size_limit ) # Return the presigned URL return Response( { "upload_data": presigned_url, "asset_id": str(asset.id), "attachment": IssueAttachmentSerializer(asset).data, "asset_url": asset.asset_url, }, status=status.HTTP_200_OK, ) def delete(self, request, slug, project_id, issue_id, pk): issue_attachment = FileAsset.objects.get( pk=pk, workspace__slug=slug, project_id=project_id ) issue_attachment.is_deleted = True issue_attachment.deleted_at = timezone.now() issue_attachment.save() issue_activity.delay( type="attachment.activity.deleted", requested_data=None, actor_id=str(self.request.user.id), issue_id=str(issue_id), project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), notification=True, origin=request.META.get("HTTP_ORIGIN"), ) # Get the storage metadata if not issue_attachment.storage_metadata: get_asset_object_metadata.delay(str(issue_attachment.id)) issue_attachment.save() return Response(status=status.HTTP_204_NO_CONTENT) def get(self, request, slug, project_id, issue_id, pk=None): if pk: # Get the asset asset = FileAsset.objects.get( id=pk, workspace__slug=slug, project_id=project_id ) # Check if the asset is uploaded if not asset.is_uploaded: return Response( {"error": "The asset is not uploaded.", "status": False}, status=status.HTTP_400_BAD_REQUEST, ) storage = S3Storage(request=request) presigned_url = storage.generate_presigned_url( object_name=asset.asset.name, disposition="attachment", filename=asset.attributes.get("name"), ) return HttpResponseRedirect(presigned_url) # Get all the attachments issue_attachments = FileAsset.objects.filter( issue_id=issue_id, entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, workspace__slug=slug, project_id=project_id, is_uploaded=True, ) # Serialize the attachments serializer = IssueAttachmentSerializer(issue_attachments, many=True) return Response(serializer.data, status=status.HTTP_200_OK) def patch(self, request, slug, project_id, issue_id, pk): issue_attachment = FileAsset.objects.get( pk=pk, workspace__slug=slug, project_id=project_id ) serializer = IssueAttachmentSerializer(issue_attachment) # Send this activity only if the attachment is not uploaded before if not issue_attachment.is_uploaded: issue_activity.delay( type="attachment.activity.created", requested_data=None, actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder), epoch=int(timezone.now().timestamp()), notification=True, origin=request.META.get("HTTP_ORIGIN"), ) # Update the attachment issue_attachment.is_uploaded = True issue_attachment.created_by = request.user # Get the storage metadata if not issue_attachment.storage_metadata: get_asset_object_metadata.delay(str(issue_attachment.id)) issue_attachment.save() return Response(status=status.HTTP_204_NO_CONTENT)